From cc788e4410010084a7691e7a1fd7982a2092be49 Mon Sep 17 00:00:00 2001 From: yungleballz Date: Sat, 24 Feb 2024 07:10:56 +0000 Subject: [PATCH 001/247] Translated using Weblate (German) Currently translated at 100.0% (251 of 251 strings) Translation: Moonlight Game Streaming/moonlight-android Translate-URL: https://hosted.weblate.org/projects/moonlight/moonlight-android/de/ --- app/src/main/res/values-de/strings.xml | 43 ++++++++++++++++---------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 03788348a7..8094135082 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -19,7 +19,7 @@ Details anzeigen Netzwerkverbindung wird getestet - Moonlight testet Ihre Netzwerkverbindung, um festzustellen, ob NVIDIA GameStream blockiert ist. + Moonlight testet Ihre Netzwerkverbindung, um festzustellen, ob benötigte Ports blockiert sind. \n \nDies kann einige Sekunden dauern… Netzwerktest abgeschlossen @@ -43,7 +43,7 @@ Koppeln bereits im Gange Host ist online - Host konnte nicht aufgeweckt werden, da GFE keine MAC-Adresse gesendet hat + Host konnte nicht aufgeweckt werden, da keine MAC-Adresse gespeichert ist Host wird aufgeweckt… Es kann einige Momente dauern deinen Host aufzuwecken. Sollte dies fehlschlagen, stelle bitte sicher, dass Wake-On-LAN korrekt konfiguriert ist. Senden der Wake-On-LAN Pakete ist fehlgeschlagen @@ -58,7 +58,7 @@ Hostname konnte nicht aufgelöst werden GFE ist auf einen HTTP 404 Fehler gestoßen. Stelle sicher, dass die GPU deines Hosts unterstützt wird. Die Verwendung von Remote-Desktop Software kann ebenso diesen Fehler verursachen. Starte deinen Host neu oder installiere GFE neu. Video Dekodierer ist abgestürzt - Moonlight ist wegen einer Inkompatibilität zu dem Video Dekodierer deines Gerätes abgestürzt. Stelle sicher, dass die GeForce Experience Version auf deinem Host auf dem neuesten Stand ist. Sollten weiterin Abstürze auftreten, versuche deine Streaming Einstellungen zu ändern. + Moonlight ist wegen einer Inkompatibilität zu dem Video Dekodierer deines Gerätes abgestürzt. Sollten weiterhin Abstürze auftreten, ändere deine Streaming Einstellungen. Video Einstellungen zurücksetzen Der Video Dekodierer deines Geräts ist wiederholt mit den ausgewählten Einstellungen abgestürzt. Deine Streamingeinstellungen wurden zurückgesetzt. USB Zugriff ist administrativ unterbunden. Bitte überprüfen sie ihre Knox oder MDM Einstellungen. @@ -77,12 +77,12 @@ Verbindungsfehler Start fehlgeschlagen Verbindung beendet - Die Verbindung wurde beendet + Die Verbindung wurde beendet. IP-Adresse des GeForce Hosts - Suche nach Hosts auf denen GeForce Experience aktiv ist… + Suche nach Host PCs in deinem lokalen Netzwerk… \n -\nStelle sicher, dass GameStream in den GeForce Experience SHIELD-Einstellungen aktiviert ist. +\nStelle sicher, dass Sunshine läuft oder GameStream in den GeForce Experience SHIELD-Einstellungen aktiviert ist. Ja Nein Verbindung zum Host verloren @@ -191,35 +191,33 @@ Warnhinweise deaktivieren On-Screen Warnmeldungen während des Streaming deaktivieren Kann das Mikro-Ruckeln auf einigen Geräten reduzieren, allerdings erhöht dies gleichzeitig die Latenz - HEVC Einstellungen ändern - HEVC verringert die Video-Bandbreitenanforderung, funktioniert allerdings nur auf sehr neuen Geräten + Codec-Einstellungen ändern + Neuere Codecs können die Anforderungen an die Videobandbreite senken, wenn diese vom Gerät unterstützt werden. Codec-Auswahl kann ignoriert werden, sollte diese von der Host-Software oder GPU nicht unterstützt werden. HDR aktivieren (experimentell) - HDR-Streaming sofern dies von der Host-GPU unterstützt wird. HDR erfordert eine GPU der GTX 1000 Serie oder neuer. + Streame HDR, wenn das Spiel und die PC-GPU dies unterstützen. HDR erfordert eine GPU mit HEVC Main 10-Codierungsunterstützung. Performance Overlay aktivieren Leistungsmerkmale während des Streamens in Echtzeit einblenden Zeige Latenz-Informationen nach dem Streaming Anzeige einer Informationsmeldung über die Latenz nach dem Ende des Streams Nativ - Native Auflösungsmodi werden von GeForce Experience nicht offiziell unterstützt, daher wird es die Auflösung deines Host-Displays nicht selbst einstellen. Du musst sie manuell im Spiel setzen. + Native Auflösungsmodi werden unter Umständen nicht vom Streaming Server unterstützt. Wahrscheinlich musst du diese manuell für den Host einstellen. \n \nWenn du eine benutzerdefinierte Auflösung in der NVIDIA Systemsteuerung erstellst, um die Auflösung deines Geräts anzupassen, stelle bitte sicher, dass du die Warnung von NVIDIA bezüglich möglicher Monitorschäden, PC-Instabilität und anderer potenzieller Probleme gelesen und verstanden hast. \n -\nWir sind nicht verantwortlich für Probleme, die aus der Erstellung einer angepassten Auflösung auf deinem PC resultieren. +\nWir übernehmen keine Verantwortung für Probleme, die aus der Erstellung einer angepassten Auflösung auf deinem PC resultieren. \n -\nSchließlich kann es sein, dass dein Gerät oder dein Host das Streaming in nativer Auflösung nicht unterstützt. Wenn es auf deinem Gerät nicht funktioniert, hast du leider Pech gehabt. +\nEs kann sein, dass dein Monitor eine notwendige Konfiguration nicht unterstützt. In diesem Fall, kann ein Virtuelles Display Abhilfe schaffen. Allerdings kann es sein, dass dein Gerät oder dein Host das Streaming in einer bestimmten Auflösung oder Bildwiederholrate nicht unterstützt. Native Auflösungswarnung Überprüfe deine Firewall und Portweiterleitungsregeln für folgende(n) Port(s): Beim Starten des Streams auf deinem Host ist etwas schief gelaufen. \n -\nStelle sicher, dass du keine DRM-geschützten Inhalte auf deinem Host geöffnet hast. Du kannst auch versuchen, deinen Host neu zu starten. -\n -\nWenn das Problem weiterhin besteht, versuche deinen GPU-Treiber und GeForce Experience neu zu installieren. +\nStelle sicher, dass du keine DRM-geschützten Inhalte auf deinem Host geöffnet hast. Du kannst auch versuchen, deinen Host neu zu starten. Aktualisiere Offline Online Automatisch - Immer HEVC verwenden (könnte Crashes verursachen) + HEVC bevorzugen Video Frame-Pacing Lege fest, wie die Videolatenz und die flüssige Wiedergabe ausgeglichen werden sollen Natives Vollbild @@ -275,4 +273,17 @@ Keine (beide Sticks bewegen die Maus) Rechter Analogstick Linker Analogstick + Fehlercode: + Emulierte Vibrationsintensität einstellen + Verstärke oder verringere die Vibrationsintensität deines Geräts + % + Erzwingt die Steuerung der Host Maus durch das Gamepad-Touchpad, selbst wenn ein Gamepad mit Touchpad emuliert wird. + Ermöglicht unterstützten Hosts, Bewegungssensordaten anzufordern, wenn ein Gamepad mit Bewegungssensoren emuliert wird. Das Deaktivieren kann die Strom- und Netzwerknutzung leicht reduzieren, wenn im Spiel keine Bewegungssensoren verwendet werden. + Emuliere die Unterstützung von Gamepad-Bewegungssensoren + Full Range Video erzwingen (Experimentell) + Der Gamepad-Typ kann sich aufgrund der Bewegungssensor-Emulation ändern + Dies führt zu Detailverlusten in hellen und dunklen Bereichen, wenn Ihr Gerät Full Range Video Inhalte nicht richtig anzeigt. + Verwendet die integrierten Bewegungssensoren Ihres Geräts, wenn Gamepad-Sensoren von Ihrem angeschlossenen Gamepad oder Ihrer Android-Version nicht unterstützt werden. +\nHinweis: Die Aktivierung dieser Option kann dazu führen, dass Ihr Gamepad auf dem Host als PlayStation-Controller angezeigt wird. + NVIDIA GameStream End-of-Service \ No newline at end of file From d73b9e16da1f5daa844229c77a1b51e8bdf372bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Serdar=20Sa=C4=9Flam?= Date: Mon, 26 Feb 2024 08:19:59 +0000 Subject: [PATCH 002/247] Translated using Weblate (Turkish) Currently translated at 100.0% (251 of 251 strings) Translation: Moonlight Game Streaming/moonlight-android Translate-URL: https://hosted.weblate.org/projects/moonlight/moonlight-android/tr/ --- app/src/main/res/values-tr/strings.xml | 183 +++++++++++++++++++------ 1 file changed, 141 insertions(+), 42 deletions(-) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 1750e76f1b..b5ea418da3 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -1,17 +1,17 @@ - Bütün ekran kontrollerini varsayılan boyutu ve pozisyonuna sıfırlar + Tüm ekran kontrollerini varsayılan boyutu ve pozisyonuna sıfırlar Dokunmatik ekranı trackpad olarak kullan Girdi Ayarları Moonlight için kullanılacak olan dil - GeForce PC\'nin IP adresi + Ana bilgisayarın IP adresi Detaylar - Opaklığı değiştir + Saydamlığı değiştir Bağlantı Hatası Başlatılıyor Başlatma başarısız Ev-sinema sistemleri için 5.1 yada 7.1 surround sesi etkinleştir - Sesleri bilgisayarda çal + Sesleri bilgisayarda oynat Mbps Bilgisayar çevrimdışı Eşleştirme başarıyla kaldırıldı @@ -22,10 +22,10 @@ Bilgisayar çevrimdışı Eşleştiriliyor… Ağ Bağlantısı Test Ediliyor - Detayları Görüntüle + Ayrıntıları Görüntüle Ağ Bağlantısını Test Et PC\'yi sil - Eşleştirmeyi kaldır + Eşleşmeyi kaldır PC ile eşle Bütün Uygulamaları Göster Yenileniyor @@ -38,9 +38,9 @@ Yardım Tanımlanmış uygulama geçerli değil Tanımlanmış bilgisayar geçerli değil - Moonlight, NVIDIA GameStream\'in engellenip engellenmediğini belirlemek için ağ bağlantınızı test ediyor. + Moonlight, gerekli bağlantı noktalarının engellenmediğini belirlemek için ağ bağlantınızı test ediyor. \n -\nBu birkaç saniye sürebilir… +\nBu bir kaç saniye sürebilir… Ağ Testi Tamamlandı Moonlight\'ın bağlantı testi sunucularından hiçbirine erişilemediği için ağ testi gerçekleştirilemedi. İnternet bağlantınızı kontrol edin veya daha sonra tekrar deneyin. Cihazınızın mevcut ağ bağlantısı Moonlight\'ı engelliyor gibi görünüyor. Bu ağa bağlıyken İnternet üzerinden yayın akışı çalışmayabilir. @@ -52,7 +52,7 @@ Lütfen hedef bilgisayarda aşağıdaki PIN kodunu girin: Eşleştirme zaten devam ediyor Bilgisayar çevrimiçi - GFE bir MAC adresi göndermediği için bilgisayar uyandırılamıyor + Kayıtlı bir MAC adresi olmadığından bilgisayar uyandırılamıyor Bilgisayar uyandırılıyor… Yerel Ağda Uyandırma paketleri gönderilemedi Bilgisayarınızın uyanması birkaç saniye sürebilir. Eğer uyanmazsa, Yerel Ağda Uyandırma için doğru şekilde yapılandırıldığından emin olun. @@ -61,13 +61,13 @@ Ağınız Moonlight\'ı engelliyor gibi görünmüyor. Bağlanmakta hala sorun yaşıyorsanız, bilgisayarınızın güvenlik duvarı ayarlarını kontrol edin. \n \nİnternet üzerinden yayın yapmaya çalışıyorsanız, Moonlight İnternet Barındırma Aracını bilgisayarınıza yükleyin ve bilgisayarınızın internet bağlantısını kontrol etmek için birlikte verilen İnternet Akış Test Cihazını çalıştırın. - Otomatik oyun kumandası varlığı algılama - Bu seçeneğin işaretinin kaldırılması bir oyun kumandasının her zaman mevcut olmasını zorlar + Oyun kumandası varlığını otomatik algıla + Bu seçeneğin işaretini kaldırmak, oyun kumandasının her zaman mevcut olmasını sağlar Oyun kumandanız desteklemiyorsa, gürültüyü taklit etmek için cihazınızı titreştirir - Analog çubuk ölü bölgesini ayarlama + Analog çubuk ölü bölgesini ayarla Yerel Xbox oyun kumandası desteği olmayan cihazlar için yerleşik USB sürücüsünü etkinleştirir - Oyun kumandası üzerinden fare emülasyonu - Geri ve ileri fare düğmelerini etkinleştirme + Oyun kumandası üstünden fare taklidi + Geri ve ileri fare düğmelerini etkinleştir Ekran Kontrolleri Ayarları Ekran kontrollerini göster ComputerManager hizmeti çalışmıyor. Lütfen birkaç saniye bekleyin veya uygulamayı yeniden başlatın. @@ -76,18 +76,16 @@ Cihazınızın video kod çözücüsü, seçtiğiniz akış ayarlarında çökmeye devam ediyor. Akış ayarlarınız varsayılana sıfırlandı. USB erişimi cihaz yöneticiniz tarafından yasaklanmıştır. Knox veya MDM ayarlarınızı kontrol edin. Mevcut başlatıcınız sabitlenmiş kısayollar oluşturmaya izin vermiyor. - Video kod çözücü başlatılamadı. Cihazınız seçilen çözünürlüğü veya kare hızını desteklemiyor olabilir. + Video kod çözücüsü başlatılamadı. Cihazınız seçilen çözünürlüğü veya kare hızını desteklemiyor olabilir. Ana bilgisayardan video alınmadı. Ağ bağlantınız iyi performans göstermiyor. Video bit hızı ayarınızı düşürün veya daha hızlı bir bağlantı deneyin. - Yayını başlatırken ana bilgisayarda bir şeyler ters gitti. + Akışı başlatırken ana bilgisayarınızda bir şeyler ters gitti. \n -\nAna bilgisayarınızda DRM korumalı herhangi bir içeriğin açık olmadığından emin olun. Ana bilgisayarınızı yeniden başlatmayı da deneyebilirsiniz. -\n -\nSorun devam ederse GPU sürücülerinizi ve GeForce Experience\'ı yeniden yüklemeyi deneyin. +\nBilgisayarınızda DRM korumalı bir içeriğin açık olmadığından emin olun. Ayrıca ana bilgisayarınızı yeniden başlatmayı da deneyebilirsiniz. Bağlantı noktaları için güvenlik duvarınızı ve bağlantı noktası yönlendirme kurallarınızı kontrol edin: - Bağlantı kuruluyor + Bağlantı Kuruluyor Bağlantı sonlandırıldı - Bağlantı sonlandırıldı + Bağlantı sonlandırıldı. Evet Bilgisayar ile bağlantı kesildi Yardım @@ -101,15 +99,15 @@ İşleme kare hızı: %1$.2f FPS Ağ bağlantınız tarafından düşen kare sayısı: %1$.2f%% Ortalama kod çözme süresi: %1$.2f ms - Oturumu sürdür - Oturumu sonlandır - Mevcut oyundan çık ve başla + Oturumu Sürdür + Oturumu Sonlandır + Mevcut Oyundan Çık ve Başlat İptal - Detayları göster - Kısayol oluştur - Kanala ekle - Uygulamayı gizle - Uygulama listesi + Ayrıntıları Göster + Kısayol Oluştur + Kanala Ekle + Uygulamayı Gizle + Uygulama Listesi Başarıyla çıkıldı Uygulama listesi alınamadı Çıkılıyor @@ -122,7 +120,7 @@ Bilgisayar adresi çözümlenemiyor. Adreste yazım hatası yapmadığınızdan emin olun. Bir IP adresi girmelisiniz Temel Ayarlar - Görüntü netliğini artırmak için artırın. Düşük uçlu cihazlarda ve daha yavaş ağlarda daha iyi performans için azaltın. + Görüntü netliğini artırmak için yükseltin. Düşük güçteki cihazlarda ve daha yavaş ağlarda daha iyi performans için azaltın. Yerel çözünürlük uyarısı Video kare hızı Video bit hızı @@ -131,7 +129,7 @@ Uyarı: Aktif ağ bağlantınız ölçülüdür! Ortalama kare kod çözme gecikmesi: donanım kod çözücü gecikmesi: - Yerel Ağda Uyandırma isteği gönder + Yerel ağdan uyandırma isteği gönder Uygulamalar yenileniyor… Hata Bilgisayarı manuel olarak Ekle @@ -144,33 +142,134 @@ Not: Bazı oyunlar Moonlight\'ın kullanmak üzere yapılandırıldığından daha büyük bir ölü bölge uygulayabilir. % Xbox 360/One USB oyun kumandası sürücüsü - Yerel Xbox oyun kumandası desteğini geçersiz kılma + Xbox doğal oyun kumanda desteğini geçersiz kıl Başlat düğmesine uzun süre basmak oyun kumandasını fare moduna geçirir Bu seçeneği etkinleştirmek bazı hatalı cihazlarda sağ tıklamayı bozabilir - Yüz düğmelerini çevirme + Yüzey düğmelerini çevir Uzak masaüstü fare modu GFE bir HTTP 404 hatası bildirdi. Bilgisayarınızın desteklenen bir GPU çalıştırdığından emin olun. Uzak masaüstü yazılımı kullanmak da bu hataya neden olabilir. Cihazınızı yeniden başlatmayı veya GFE\'yi yeniden yüklemeyi deneyin. - Moonlight, bu cihazın video kod çözücüsü ile uyumsuzluk nedeniyle çöktü. GeForce Experience\'ın bilgisayarınızdaki en son sürüme güncellendiğinden emin olun. Çökmeler devam ederse akış ayarlarını değiştirmeyi deneyin. + Moonlight, bu cihazın video kod çözücüsüyle uyumsuzluk nedeniyle çöktü. Kilitlenmeler devam ederse akış ayarlarını değiştirmeyi deneyin. Ortalama donanım kod çözme gecikmesi: Video Ayarlarını Sıfırla - GameStream\'in çalıştığı bilgisayarlar aranıyor... + Yerel ağınızdaki ana bilgisayarlar aranıyor… \n -\n GeForce Experience SHIELD ayarlarında GameStream\'in etkinleştirildiğinden emin olun. +\n Sunshine ana bilgisayarınızda çalıştığından veya GeForce Experience SHIELD ayarlarında GameStream etkinleştirildiğinden emin olun. Bilgisayara bağlanıyor… Hayır Ortalama ağ gecikmesi: %1$d ms (varyans: %2$d ms) Bu adres doğru görünmüyor. İnternet üzerinden akış için yönlendiricinizin genel IP adresini kullanmanız gerekir. - Yerel çözünürlük modları GeForce Experience tarafından resmi olarak desteklenmez, bu nedenle ana ekran çözünürlüğünüzü kendisi ayarlamaz. Oyun içindeyken manuel olarak ayarlamanız gerekecektir. + Yerel çözünürlük ve/veya FPS, akış sunucusu tarafından desteklenmeyebilir. Muhtemelen ana bilgisayar için uygun bir özel ekran kipini el ile yapılandırmanız gerekecek. \n -\nCihazınızın çözünürlüğüne uyması için NVIDIA Denetim Masası\'nda özel bir çözünürlük oluşturmayı seçerseniz, lütfen NVIDIA\'nın olası monitör hasarı, bilgisayar kararsızlığı ve diğer olası sorunlarla ilgili uyarısını okuyup anladığınızdan emin olun. +\nNVIDIA Kontrol Panelinde ekran ayarlarınızla eşleşmesi için özel bir çözünürlük oluşturmayı seçerseniz lütfen NVIDIA tarafından olası monitör hasarı, bilgisayar kararsızlığı ve diğer olası sorunlarla ilgili uyarısını okuyup anladığınızdan emin olun. \n -\nBilgisayarınızda özel bir çözünürlük oluşturulmasından kaynaklanan herhangi bir sorundan sorumlu değiliz. +\nBilgisayarınızda özel bir çözünürlük oluşturulmasından kaynaklanan sorunlardan sorumlu değiliz. \n -\nSon olarak, cihazınız veya bilgisayarınız doğal çözünürlükte akışı desteklemiyor olabilir. Eğer cihazınızda çalışmıyorsa, ne yazık ki şansınız yok demektir. +\nMonitörünüz gerekli ekran yapılandırmasını desteklemiyor olabilir. Eğer öyleyse, sanal bir monitör kurmayı deneyebilirsiniz. Son olarak, cihazınız veya ana bilgisayarınız belirli bir çözünürlükte veya yenileme hızında akışı desteklemiyorsa ne yazık ki şansınız kalmaz. Daha iyi görüntü kalitesi için artırın. Daha yavaş bağlantılarda performansı artırmak için azaltın. Videoyu tam ekrana genişlet - Titreşim ile gürültü desteğini taklit etme + Titreşim ile gürültü desteğini taklit et Yerel Xbox oyun kumandası desteği mevcut olsa bile desteklenen tüm oyun kumandaları için Moonlight\'ın USB sürücüsünü kullan - Oyun kumandaları ve ekran kontrolleri için A/B ve X/Y yüz düğmelerini değiştirir + Oyun kumandaları ve ekran kontrolleri için A/B ve X/Y yüzey düğmelerini değiştirir Bu fare hızlandırmanın uzak masaüstü kullanımı için daha doğal davranmasını sağlayabilir, ancak birçok oyunla uyumsuzdur. + Dokunmatik ekranda sanal denetleyici katmanını göster + Düzeni Sıfırla + Dil + Ana Bilgisayar Ayarları + Ana bilgisayarınız Sunshine çalıştırıyorsa PIN girmek için Sunshine web kullanıcı arayüzüne gidin. + Ana bilgisayar önemli bir video kodlama hatası bildirdi. +\n +\nHDR kipi devre dışı bırakmayı, akış çözünürlüğünü değiştirmeyi veya ana bilgisayarın ekran çözünürlüğünü değiştirmeyi deneyin. + Oyun ve PC GPU desteklediğinde HDR akışı gerçekleştirin. HDR, HEVC Main 10 kodlama desteğine sahip bir GPU gerektirir. + Hata kodu: + Ana makine işleme gecikmesi asg/azm/ortalama: %1$.1f/%2$.1f/%3$.1f ms + FPS + Yerel FPS Uyarısı + Sistem ekolayzır desteğini etkinleştir + Oyun Kumandası Ayarları + Taklit edilmiş gürültü yoğunluğunu ayarla + % + Cihazınızdaki titreşim yoğunluğunu artırın veya azaltın + Fareyi her zaman dokunmatik yüzeyle kontrol et + Dokunmatik yüzeyli bir oyun kumandasını taklit ederken bile, oyun kumandası dokunmatik yüzey girişini ana fareyi kontrol etmeye zorlar. + Oyun kumandası hareket sensörü kullanımına izin ver + Oyun kumandası hareket sensörü desteğini taklit et + Titreşimi etkinleştir + Ekrandaki kontrollerde gürültüyü taklit etmek için cihazınızı titretir + Yalnızca L3 ve R3 göster + L3 ve R3 dışındaki tüm sanal düğmeleri gizle + Kaydedilen ekran kontrolleri düzenini temizle + Kaydedilmiş ekran kontrolleri düzeninizi silmek istediğinizden emin misiniz? + Ekrandaki kontroller varsayılana sıfırlandı + Ekrandaki kontrollerin saydamlığını değiştir + % + Arayüz Ayarları + Resim İçinde Resim gözlemci modunu etkinleştir + Çoklu görev sırasında akışın görüntülenmesine (ancak kontrol edilmemesine) olanak tanır + Ekrandaki kontrolleri daha fazla/daha az saydam hale getirin + En iyi akış için GFE oyun ayarlarını değiştirmesine izin ver + Gelişmiş Ayarlar + Mümkün olan tüm kare hızlarının kilidini aç + Küçük kutu resmi kullan + Uygulama kılavuzunda küçük kutu resimleri, ekranda daha fazla uygulamanın görünmesini sağlar + 90 veya 120 FPS yayın yapmak ileri teknoloji cihazlarda gecikmeyi azaltabilir ancak bunu desteklemeyen cihazlarda gecikmeye veya kararsızlığa neden olabilir + Yenileme hızının azaltılmasına izin ver + Daha düşük ekran yenileme hızları, bazı ek video gecikmeleri pahasına güç tasarrufu sağlayabilir + Akış sırasında ekrandaki bağlantı uyarı mesajlarını devre dışı bırakın + Asla kare hızlarını düşürme + Çözücü ayarlarını değiştir + Daha yeni çözücü bileşenleri, cihazınız destekliyorsa video bant genişliği gereksinimlerini azaltabilir. Ana yazılım veya GPU tarafından desteklenmiyorsa çözücü seçimleri göz ardı edilebilir. + HDR etkinleştir (Deneysel) + Tam aralıklı videoyu zorla (Deneysel) + Cihazınız tam kapsamlı video içeriğini düzgün şekilde görüntülemiyorsa, bu durum aydınlık ve karanlık alanlarda ayrıntı kaybına neden olacaktır. + Kurulum rehberi + Akıştan sonra gecikme mesajını göster + Yardım + Akış sona erdikten sonra bir gecikme bilgi mesajı görüntüle + Oyun bilgisayarınızı akış için nasıl ayarlayacağınıza ilişkin talimatları görüntüleyin + Sorun giderme kılavuzu + Gizlilik politikası + 480p + 1080p + Yaygın akış sorunlarını teşhis etmeye ve düzeltmeye yönelik ipuçlarını görüntüle + Moonlight gizlilik politikasını görüntüleyin + Hareket sensörü taklidi nedeniyle oyun kumandası türü değiştirilebilir + 5.1 Surround Ses + 7.1 Surround Ses + Otomatik (Önerilen) + 1440p + 4K + 60 FPS + 90 FPS + 120 FPS + Stereo + Video kare ilerleme hızı + Video gecikmesinin ve akıcılığının nasıl dengeleneceğini belirtin + Düşük gecikmeyi tercih et + Dengeli + AV1 tercih et (Deneysel) + HEVC tercih et + H.264 tercih et + FPS limiti ile dengeli + En akıcı videoyu tercih et (gecikmeyi önemli ölçüde artırabilir) + Kaydırmak için analog çubuk kullan + Fare taklit etme kipindeyken kaydırmak için bir analog çubuk seçin + Yok (her iki çubuk da fareyi hareket ettirir) + Sol analog çubuk + Sağ analog çubuk + Sesleri bilgisayardan ve bu cihazdan oynat + Oyun ayarlarını iyileştir + Uyarı mesajlarını devre dışı bırak + (Manzara) + (Portre) + Bazı cihazlarda mikro kekemeliği azaltabilir ancak gecikmeyi artırabilir + 360p + NVIDIA GameStream Hizmet Şartları + 720p + 30 FPS + Ses efektlerinin akış sırasında çalışmasına izin verir ancak ses gecikmesini artırabilir + Desteklenen ana bilgisayarların, hareket sensörlü bir oyun kumandasını taklit ederken hareket sensörü verilerini istemesine olanak tanır. Oyunda hareket sensörleri kullanılmıyorsa devre dışı bırakmak, güç ve ağ kullanımını bir miktar azaltabilir. + Oyun kumandası sensörleri Android sürümünüz tarafından desteklenmiyorsa cihazınızın yerleşik hareket sensörlerini kullanır. +\nNot: Bu seçeneğin etkinleştirilmesi, oyun kumandanızın ana bilgisayarda PlayStation denetleyicisi olarak görünmesine neden olabilir. + Akış sırasında performans istatistiklerini göster + Akış sırasında gerçek zamanlı akış performansı bilgilerini görüntüleyin \ No newline at end of file From 2716c68c7a80b6fd3578a78bbbb7a1746001be15 Mon Sep 17 00:00:00 2001 From: Yutaro Urata Date: Mon, 8 Apr 2024 16:39:44 +0000 Subject: [PATCH 003/247] Translated using Weblate (Japanese) Currently translated at 49.0% (123 of 251 strings) Translation: Moonlight Game Streaming/moonlight-android Translate-URL: https://hosted.weblate.org/projects/moonlight/moonlight-android/ja/ --- app/src/main/res/values-ja/strings.xml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 92d25d6d31..af08086c5d 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -137,4 +137,14 @@ ネットワークテスト終了 詳細を表示 ネットワーク接続テスト中 + デバイスの設定が Moonlight をブロックしているようです。このネットワークに接続している間ストリーミングが使えないかもしれません。 + このデバイスのビデオデコーダーが Moonlight と非対応なため Moonlight がクラッシュしました。クラッシュが継続するようでしたらストリームの設定を変更してください。 + デバイスの設定が Moonlight をブロックしているようです。このネットワークに接続している間ストリーミングが使えないかもしれません。 +\n +\n次のネットワークポートがブロックされています: +\n + ビデオデコーダーがクラッシュしました + ビデオ設定リセット + ホーストのコンピューターが Sunshine を使用している場合、Sunshineにピンを入れてください。 + ペアリング中です \ No newline at end of file From cf64d102de5a6393c2b02fbc71fc4bef78389560 Mon Sep 17 00:00:00 2001 From: NephilimDM Date: Wed, 24 Apr 2024 10:02:53 +0000 Subject: [PATCH 004/247] Translated using Weblate (Italian) Currently translated at 96.4% (242 of 251 strings) Translation: Moonlight Game Streaming/moonlight-android Translate-URL: https://hosted.weblate.org/projects/moonlight/moonlight-android/it/ --- app/src/main/res/values-it/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 8913aea019..0a09261c69 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -57,7 +57,7 @@ Errore connessione Avvio fallito Connessione interrotta - La connessione è stata interrotta + La connessione è stata interrotta. Indirizzo IP del PC Ricerca di PC nella rete locale… From e48c38f47a9dbe300e18da898b4293c5b0511636 Mon Sep 17 00:00:00 2001 From: Marocco2 Date: Fri, 24 May 2024 12:39:15 +0000 Subject: [PATCH 005/247] Translated using Weblate (Italian) Currently translated at 100.0% (251 of 251 strings) Translation: Moonlight Game Streaming/moonlight-android Translate-URL: https://hosted.weblate.org/projects/moonlight/moonlight-android/it/ --- app/src/main/res/values-it/strings.xml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 0a09261c69..467d4ceff4 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -279,4 +279,13 @@ Se il tuo PC host sta eseguendo Sunshine, vai nella pagina web locale di Sunshine ed inserisci il PIN. Controlla sempre il mouse col touchpad Latenza di codifica da parte dell\'host min/max/average: %1$.1f/%2$.1f/%3$.1f ms + Codice di errore: + Aggiusta l\'intensità della vibrazione emulata + Amplifica o riduci l\'intensità della vibrazione sul tuo dispositivo + % + Utilizza lo stick analogico per scorrere + Seleziona uno stick analogico per scorrere durante la modalità di emulazione del mouse + Nessuna (entrambi gli stick muovono il mouse) + Stick analogico destro + Stick analogico sinistro \ No newline at end of file From 9ef23038abcdf47ecf3babade86fa9b7ac9b9f8c Mon Sep 17 00:00:00 2001 From: Gunawan Wibisono Date: Fri, 24 May 2024 03:52:20 +0000 Subject: [PATCH 006/247] Translated using Weblate (Indonesian) Currently translated at 74.5% (187 of 251 strings) Translation: Moonlight Game Streaming/moonlight-android Translate-URL: https://hosted.weblate.org/projects/moonlight/moonlight-android/id/ --- app/src/main/res/values-in/strings.xml | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index d112010d10..4959900597 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -30,7 +30,7 @@ Pemasangan gagal Pemasangan sedang berlangsung Komputer tersedia - Tidak bisa membangunkan komputer karena GFE tidak mengirimkan alamat MAC + Tidak bisa membangunkan komputer karena tidak ada alamat MAC yang tersimpan Membangunkan komputer… Membangunkan komputer mungkin akan membutuhkan beberapa saat. Jika tidak bisa, pastikan konfigurasi Wake-On-LAN sudah benar. Memutuskan pemasangan… @@ -38,7 +38,7 @@ Gagal memutuskan pemasangan Komputer tidak tersedia Dekoder video berhenti bekerja - Moonlight telah berhenti bekerja dikarenakan inkompatibilitas dengan dekoder video perangkat ini. Pastikan GeForce Experience sudah diperbarui ke versi terkini pada komputer Anda. Coba ganti pengaturan stream jika masih berlanjut. + Moonlight telah berhenti bekerja dikarenakan inkompatibilitas dengan dekoder video perangkat ini. Coba ganti pengaturan stream jika masih berlanjut. Pengaturan video dipulihkan Dekoder video perangkat Anda terus-menerus berhenti pada pengaturan stream yang dipilih. Pengaturan stream Anda telah dipulihkan ke bawaan. Akses USB dilarang oleh Administrator perangkat Anda. Cek pengaturan Knox atau MDM Anda. @@ -55,8 +55,8 @@ Koneksi eror Gagal untuk memulai Koneksi dihentikan - Koneksi telah terhenti - Alamat IP komputer GeForce + Koneksi telah terputus. + Alamat IP komputer host Mencari komputer yang menjalankan GameStream... \n \nPastikan GameStream diaktifkan pada pengaturan SHIELD GeForce Experience. @@ -83,15 +83,13 @@ Komputer sedang ada di game. Anda harus menutupnya terlebih dahulu. Gagal mengirim paket Wake-On-LAN Perangkat tidak terpasang - ComputerManager tidak berjalan. Mohon tunggu beberapa saat atau mulai ulang aplikasi. + Servis ComputerManager tidak berjalan. Mohon tunggu beberapa detik atau mulai ulang aplikasi. GFE memberikan eror HTTP 404. Pastikan komputer anda menggunakan kartu grafis yang didukung. Penggunaan aplikasi remote desktop juga dapat menyebabakan eror ini. Coba mulai ulang perangkat Anda atau install ulang GFE. Peluncur Anda tidak memperbolehkan pembuatan shortcut yang disematkan. Gagal memulai dekoder video. Perangkat Anda mungkin tidak mendukung resolusi atau kecepatan bingkai yang dipilih. - Ada yang salah dengan komputer host anda ketika memulai stream. + Ada yang salah dengan komputer host Anda ketika memulai stream. \n -\nPastikan tidak ada konten yang dilindungi DRM yang terbuka di komputer host Anda. Coba mulai ulang komputer host. -\n -\nJika masalah berkelanjutan, coba install ulang GeForce Experience dan driver video grafis Anda. +\nPastikan tidak ada konten yang dilindungi DRM yang terbuka di komputer host Anda. Coba mulai ulang komputer host. Bantuan NVIDIA GameStream End-of-Service Lanjutkan Sesi @@ -211,4 +209,5 @@ Aktifkan mode pengamat Gambar-dalam-Gambar Bahasa yang digunakan untuk Moonlight Izinkan GFE mengubah setelan game untuk streaming yang optimal + Kode galat: \ No newline at end of file From f97704764a1268c6e363ac73ca3acb2fcfa4eb22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D1=80=D1=82=D1=91=D0=BC=20=D0=96=D1=83=D1=80=D0=B8?= =?UTF-8?q?=D0=BD?= Date: Thu, 20 Jun 2024 06:12:46 +0000 Subject: [PATCH 007/247] Translated using Weblate (Russian) Currently translated at 100.0% (251 of 251 strings) Translation: Moonlight Game Streaming/moonlight-android Translate-URL: https://hosted.weblate.org/projects/moonlight/moonlight-android/ru/ --- app/src/main/res/values-ru/strings.xml | 66 ++++++++++++++++++-------- 1 file changed, 47 insertions(+), 19 deletions(-) diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 93a2736023..d29479a20b 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -8,7 +8,9 @@ Удалить PC Тестирование Сетевого Подключения - Moonlight тестирует ваше сетевое подключение, чтобы определить, заблокирован ли NVIDIA GameStream.\n\nЭто может занять некоторое время… + Moonlight тестирует ваше сетевое подключение, чтобы определить, заблокированы ли нужные порты. +\n +\nЭто может занять некоторое время… Тестирование Подключения Завершено Ваша сеть не блокирует Moonlight. Если у вас всё ещё есть проблемы с соединением, проверьте настройки брандмауэра на компьютере.\n\nЕсли вы пытаетесь транслировать через интернет, установите Moonlight Internet Hosting Tool на ваш компьютер и запустите установленный Internet Streaming Tester, чтобы проверить Интернет-соединение на вашем компьютере. Тестирование подключения не может быть выполнено, потому что сервера тестирования подключения Moonlight не доступны. Проверьте ваше Интернет-соединение или повторите попытку позже. @@ -23,7 +25,7 @@ Создание пары не удалось Компьютер в сети - Невозможно разбудить PC потому что GFE не отправило MAC адрес + Невозможно разбудить компьютер, поскольку нет сохраненного MAC-адреса Пробуждение PC… Пробуждение PC может занять несколько секунд. Если этого не происходит, удостоверьтесь что Wake-On-LAN настроен правильно. @@ -52,11 +54,12 @@ Ошибка соединения Запуск не удался Соединение прекращено - Подключение было прервано + Подключение было прервано. - IP-адрес компьютера с GeForce - Поиск компьютеров с запущенным GameStream…\n\n - Убедитесь что GameStream включен в настройках GeForce Experience в разделе SHIELD. + IP-адрес хост-ПК + Поиск хост-ПК в вашей локальной сети… +\n +\n Убедитесь, что Sunshine работает на вашем хост-компьютере или GameStream включен в настройках GeForce Experience SHIELD. Да Нет Потеряно соединение с PC @@ -112,8 +115,8 @@ Проигрывать звук на PC Проигрывать звук на компьютере и текущем устройстве Расширенные Настройки - Изменить настройки HEVC - HEVC снижает требования к пропускной способности, но требует очень нового устройства + Изменить настройки кодека + Новые кодеки могут снизить требования к пропускной способности видео, если ваше устройство их поддерживает. Выбор кодека можно игнорировать, если он не поддерживается ПО хоста или графическим процессором. Настройки Экранных Кнопок Показывать экранные кнопки Отображать оверлей виртуального контроллера на сенсорном экране @@ -129,7 +132,7 @@ Помощь Подключение к PC… Сбой Видео Декодера - Произошел сбой Moonlight из-за проблем с видео декодером данного устройства. Попробуйте изменить настройки трансляции если сбои будут продолжаться. + Moonlight произошел сбой из-за несовместимости с видеодекодером этого устройства. Попробуйте изменить настройки потоковой передачи, если сбои продолжаются. Видео Настройки Сброшены Видео декодер Вашего устройства давал сбои с выбранными настройками. Настройки трансляции были сброшены до значений по умолчанию. USB доступ запрещен администратором устройства. Проверьте настройки Knox или MDM. @@ -148,7 +151,7 @@ Никогда не пропускать кадры Может уменьшить микрозависания на некоторых устройствах, но также увеличить задержку Включить HDR (Экспериментально) - Транслировать в HDR если игра и GPU компьютера поддерживают это. HDR требует видеокарты GTX 1000 серии или более новой. + Потоковая передача с HDR, когда игра и графический процессор ПК поддерживают эту функцию. Для HDR требуется графический процессор с поддержкой кодирования HEVC Main 10. Показывать отчёт о задержке после трансляции Отобразить сообщение с информацией о задержке после окончания трансляции. Включить вибрацию @@ -188,26 +191,24 @@ Скрыть приложение Тестовое подключение к сети Обновление - Оффлайн - Онлайн + Не в сети + В сети Изменить прозрачность экранных элементов управления Проверьте свой брандмауэр и правила переадресации портов для порта(ов): - Что-то пошло не так на вашем хост-компьютере при запуске трансляции. -\n -\nУбедитесь, что на вашем компьютере нет содержимого, защищенного DRM. Вы также можете попробовать перезагрузить компьютер. + При запуске потока на вашем хост-компьютере что-то пошло не так. \n -\nЕсли проблема сохраняется, попробуйте переустановить драйверы для GPU и GeForce Experience. +\nУбедитесь, что на вашем хост-ПК не открыт контент, защищенный DRM. Вы также можете попробовать перезагрузить хост-компьютер. Ваше сетевое подключение не справляется с заданными настройками. Попробуйте изменить битрейт или использовать более быстрое подключение. Видео от хоста не получено. Текущее сетевое соединение вашего устройства блокирует Moonlight. Потоковая передача через Интернет может не работать при подключении к этой сети. Родное - Режимы родного разрешения официально не поддерживаются GeForce Experience, поэтому он не будет устанавливать разрешение экрана вашего хоста самостоятельно. Вам нужно будет установить его вручную во время игры. + Режимы родного разрешения официально не поддерживаются GeForce Experience, поэтому он не будет устанавливать разрешение экрана вашего хоста самостоятельно. Вам нужно будет установить его вручную. \n \nЕсли вы решите создать пользовательское разрешение в Панели управления NVIDIA в соответствии с разрешением вашего устройства, убедитесь, что вы прочитали и поняли предупреждение NVIDIA относительно возможного повреждения монитора, нестабильности ПК и других потенциальных проблем. \n \nМы не несем ответственности за какие-либо проблемы, возникающие в результате создания пользовательского разрешения на вашем ПК. \n -\nНаконец, ваше устройство или хост-компьютер может не поддерживать потоковую передачу в родном разрешении. Если это не работает на вашем устройстве, к сожалению, вам просто не повезло. +\nНаконец, ваше устройство или хост-ПК может не поддерживать потоковую передачу в родном разрешении. Если это не работает на вашем устройстве, к сожалению, вам просто не повезло. Предупреждение о родном разрешении Видеодекодер не инициализирован. Ваше устройство может не поддерживать выбранное разрешение или частоту кадров. Видеострим: %1$s %2$.2f FPS @@ -218,7 +219,7 @@ 5.1 Объёмный звук 7.1 Объёмный звук Автоматически - Всегда использовать HEVC если доступно + Предпочитать HEVC Минимальная задержка Баланс Максимальная плавность (может значительно увеличить задержку) @@ -254,4 +255,31 @@ Скорость вывода/отрисовки кадра Сбалансированно с лимитом FPS 480p + FPS + Встроенное предупреждение о FPS + Настройки геймпада + Отрегулируйте интенсивность эмулируемой вибрации + % + Принудительное полнодиапазонное видео (экспериментальное) + Это приведет к потере детализации в светлых и темных областях, если ваше устройство не отображает видеоконтент в полном диапазоне должным образом. + Тип геймпада может быть изменен из-за эмуляции датчика движения + Предпочитать AV1 (экспериментальный) + Использовать аналоговый джойстик для прокрутки + Правый аналоговый джойстик + Если на вашем хост-ПК установлен Sunshine, перейдите в веб-интерфейс Sunshine и введите PIN-код. + Код ошибки: + Задержка обработки хоста мин/макс/среднее: %1$.1f/%2$.1f/%3$.1f мс + Увеличьте или уменьшите интенсивность вибрации на вашем устройстве + Заставляет ввод с сенсорной панели геймпада управлять мышью хоста, даже при эмуляции геймпада с помощью тачпада. + Разрешить использование датчиков движения геймпада + Всегда управлять мышью с помощью тачпада + Эмулировать поддержку датчика движения геймпада + Позволяет поддерживаемым хостам запрашивать данные датчиков движения при эмуляции геймпада с датчиками движения. Отключение может немного снизить энергопотребление и использование сети, если датчики движения не используются в игре. + Использует встроенные датчики движения вашего устройства, если датчики геймпада не поддерживаются подключенным геймпадом или версией Android. +\nПримечание. Включение этой опции может привести к тому, что ваш геймпад будет отображаться на хосте как контроллер PlayStation. + Предпочитать H.264 + Выбрать аналоговый джойстик для прокрутки в режиме эмуляции мыши + Нет (оба джойстика перемещают мышь) + Левый аналоговый джойстик + NVIDIA GameStream Обслуживание окончено \ No newline at end of file From 454b443b818aa9aba5fb08474667cd0b4e8526c6 Mon Sep 17 00:00:00 2001 From: sanhoe Date: Tue, 25 Jun 2024 13:29:51 +0000 Subject: [PATCH 008/247] Translated using Weblate (Korean) Currently translated at 100.0% (251 of 251 strings) Translation: Moonlight Game Streaming/moonlight-android Translate-URL: https://hosted.weblate.org/projects/moonlight/moonlight-android/ko/ --- app/src/main/res/values-ko/strings.xml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 45d4ff9021..f70e31eebe 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -50,7 +50,7 @@ 연결 오류 시작 실패 연결 종료 됨 - 연결이 종료되었습니다 + 연결이 종료되었습니다. 호스트 PC의 IP 주소 로컬 네트워크의 호스트 PC를 검색중… @@ -244,7 +244,7 @@ 균형 FPS 제한과 균형 시스템 이퀄라이저 지원 활성화 - 스트리밍중 오디오 효과가 작동하도록 하지만 오디오 지연시간이 증가할 수 있습니다. + 스트리밍 중 오디오 효과가 작동하도록 하지만 오디오 지연시간이 증가할 수 있습니다. 원격 데스크톱 마우스 모드 이 옵션을 활성화하면 마우스가 보다 자연스럽게 동작할 수 있지만 많은 게임과 호환되지 않습니다(특히 FPS에서). 60 FPS @@ -278,4 +278,13 @@ 연결된 컨트롤러 또는 기기의 Android 버전에서 컨트롤러 센서가 지원되지 않는 경우 장치에 내장된 모션 센서를 사용합니다. \n참고: 이 옵션을 활성화하면 사용 중인 컨트롤러가 호스트에서 PlayStation 컨트롤러로 나타날 수 있습니다. 컨트롤러의 모션 센서 에뮬레이션 지원 + 에러 코드: + 장치의 진동 강도를 증폭하거나 감소시킵니다 + 아날로그 스틱을 사용하여 스크롤 + 없음 (두 스틱 모두 마우스를 움직입니다) + 우측 아날로그 스틱 + 좌측 아날로그 스틱 + 에뮬레이트된 진동 강도 조정 + 마우스 에뮬레이션 모드에서 스크롤할 아날로그 스틱을 선택하세요 + % \ No newline at end of file From 2df69bcdea61f404076d41791eb98164d0420242 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D1=80=D1=82=D1=91=D0=BC=20=D0=96=D1=83=D1=80=D0=B8?= =?UTF-8?q?=D0=BD?= Date: Sun, 30 Jun 2024 08:47:29 +0000 Subject: [PATCH 009/247] Translated using Weblate (Russian) Currently translated at 100.0% (251 of 251 strings) Translation: Moonlight Game Streaming/moonlight-android Translate-URL: https://hosted.weblate.org/projects/moonlight/moonlight-android/ru/ --- app/src/main/res/values-ru/strings.xml | 97 +++++++++++++------------- 1 file changed, 47 insertions(+), 50 deletions(-) diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index d29479a20b..1213e408c8 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -2,16 +2,16 @@ Посмотреть список игр - Создать пару с PC + Создать пару с ПК Разорвать пару Отправить Wake-On-LAN запрос - Удалить PC + Удалить ПК - Тестирование Сетевого Подключения + Тестирование сетевого подключения Moonlight тестирует ваше сетевое подключение, чтобы определить, заблокированы ли нужные порты. \n \nЭто может занять некоторое время… - Тестирование Подключения Завершено + Тестирование подключения завершено Ваша сеть не блокирует Moonlight. Если у вас всё ещё есть проблемы с соединением, проверьте настройки брандмауэра на компьютере.\n\nЕсли вы пытаетесь транслировать через интернет, установите Moonlight Internet Hosting Tool на ваш компьютер и запустите установленный Internet Streaming Tester, чтобы проверить Интернет-соединение на вашем компьютере. Тестирование подключения не может быть выполнено, потому что сервера тестирования подключения Moonlight не доступны. Проверьте ваше Интернет-соединение или повторите попытку позже. Похоже, что текущее сетевое подключение вашего устройства блокирует Moonlight. Трансляция через Интернет может не работать при подключении к этой сети.\n\nСледующие сетевые порты были заблокированы:\n @@ -20,16 +20,14 @@ Компьютер выключен или находится не в сети Компьютер в данный момент находится в игре. Вы должны закрыть игру перед создание пары. Создание пары - Пожалуйста введите этот PIN на PC: + Пожалуйста введите этот PIN на целевом ПК: Неправильный PIN Создание пары не удалось Компьютер в сети Невозможно разбудить компьютер, поскольку нет сохраненного MAC-адреса - Пробуждение PC… - Пробуждение PC может занять несколько секунд. - Если этого не происходит, удостоверьтесь что Wake-On-LAN настроен правильно. - + Пробуждение ПК… + Пробуждение ПК может занять несколько секунд. Если этого не происходит, удостоверьтесь что Wake-On-LAN настроен правильно. Ошибка при отправке Wake-On-LAN пакетов Разрыв пары… @@ -40,16 +38,14 @@ Компьютер выключен или находится не в сети Сервис ComputerManager не запущен. Пожалуйста, подождите несколько секунд или перезапустите приложение. Не удалось найти хост - GFE вернул ошибку HTTP 404. Убедитесь что Ваш PC использует поддерживаемый GPU. - Использование программ для удалённого доступа также может вызывать эту ошибку. Попробуйте перезагрузить компьютер или переустановить GFE. - + GFE вернул ошибку HTTP 404. Убедитесь что Ваш ПК использует поддерживаемый GPU. Использование программ для удалённого доступа также может вызывать эту ошибку. Попробуйте перезагрузить компьютер или переустановить GFE. Создание соединения Подключение Внимание: Происходит измерение Вашего сетевого соединения! Средняя задержка декодирования кадра: - задержка аппаратного декодирования: - Средняя задержка апаратного декодирования: + Задержка аппаратного декодирования: + Средняя задержка аппаратного декодирования: Запуск Ошибка соединения Запуск не удался @@ -62,7 +58,7 @@ \n Убедитесь, что Sunshine работает на вашем хост-компьютере или GameStream включен в настройках GeForce Experience SHIELD. Да Нет - Потеряно соединение с PC + Потеряно соединение с ПК Возобновить сессию Выйти из сессии @@ -78,22 +74,22 @@ Ошибка при выходе Вы уверены, что хотите выйти из запущенного приложения? Все несохраненные данные будут потеряны. - Добавление PC вручную - Соединение с PC… + Добавление ПК вручную + Соединение с ПК… Не удалось подключиться к выбранному компьютеру. Удостоверьтесь, что необходимые порты разрешены в настройках брандмауэра. Компьютер добавлен успешно - Не удалось найти PC по указанному адресу. Убедитесь, что Вы не совершили ошибок во время его написания. + Не удалось найти ПК по указанному адресу. Убедитесь, что Вы не совершили ошибок во время его написания. Вы должны ввести IP адрес - Общие Настройки + Основные настройки Выберите разрешение и частоту кадров Увеличьте для повышения чёткости изображения. Уменьшите для лучшей производительности на медленных устройствах или сетях. Выберите битрейт видео - Низкий битрейт уменьшит зависания. Увеличение битрейта улучшит качество изображения. + Увеличьте для лучшего качества изображения. Уменьшите, чтобы улучшить производительность при более медленных соединениях. Растягивать видео на весь экран Отключить сообщения с предупреждениями Выключить экранные предупреждения о соединении во время трансляции - Аудио Настройки + Аудио настройки Настройка объёмного звука Включить объёмный звук 5.1 или 7.1 для систем домашнего кинотеатра Поддержка нескольких контроллеров @@ -104,38 +100,38 @@ Включить встроенный USB драйвер для устройств без собственной поддержки контроллеров Xbox Перевернуть кнопки A/B и X/Y Меняет местами кнопки A/B и X/Y на геймпадах и экранных кнопках - Настройки Интерфейса + Настройки интерфейса Язык Язык, который будет использоваться в Moonlight Использовать маленькие иконки Использовать маленькие иконки в сетке для отображения большего числа элементов на экране - Настройки Хоста + Настройки хоста Оптимизировать игровые настройки Разрешить GFE изменять настройки игр для оптимальной трансляции - Проигрывать звук на PC + Проигрывать звук на ПК Проигрывать звук на компьютере и текущем устройстве Расширенные Настройки Изменить настройки кодека Новые кодеки могут снизить требования к пропускной способности видео, если ваше устройство их поддерживает. Выбор кодека можно игнорировать, если он не поддерживается ПО хоста или графическим процессором. - Настройки Экранных Кнопок + Настройки экранных кнопок Показывать экранные кнопки Отображать оверлей виртуального контроллера на сенсорном экране Показывать только L3 и R3 Скрывать все экранные кнопки кроме L3 и R3 Сделать экранные кнопки более/менее прозрачными Изменить прозрачность - PC удален - PC не сопряжен - Просмотр Помощи + ПК удален + ПК не сопряжен + Просмотр помощи Загрузка страницы помощи… Сопряжение уже в процессе Помощь - Подключение к PC… - Сбой Видео Декодера + Подключение к ПК… + Сбой декодирования видео Moonlight произошел сбой из-за несовместимости с видеодекодером этого устройства. Попробуйте изменить настройки потоковой передачи, если сбои продолжаются. - Видео Настройки Сброшены + Настройки видео были сброшены Видео декодер Вашего устройства давал сбои с выбранными настройками. Настройки трансляции были сброшены до значений по умолчанию. - USB доступ запрещен администратором устройства. Проверьте настройки Knox или MDM. + USB доступ запрещён администратором устройства. Проверьте настройки Knox или MDM. Адрес указан неверно. Вы должны ввести публичный IP-адрес Вашего роутера для передачи через интернет. Включить просмотр в режиме \"Картинка в картинке\" Позволяет просматривать трансляцию (но не управлять ей) во время работы в других приложениях @@ -145,7 +141,7 @@ Долгое нажатие кнопки Start переключит геймпад в режим мыши Сбросить схему расположения экранных кнопок Возвращает все экранные элементы управления к их расположениям по умолчанию - Сбросить Схему + Сбросить схему Вы уверены что хотите удалить сохранненную схему расположения кнопок? Экранные элементы управления возвращены к положениям по умолчанию Никогда не пропускать кадры @@ -153,17 +149,17 @@ Включить HDR (Экспериментально) Потоковая передача с HDR, когда игра и графический процессор ПК поддерживают эту функцию. Для HDR требуется графический процессор с поддержкой кодирования HEVC Main 10. Показывать отчёт о задержке после трансляции - Отобразить сообщение с информацией о задержке после окончания трансляции. + Отобразить сообщение с информацией о задержке после окончания трансляции Включить вибрацию Частота кадров - Детали + Посмотреть детали Создать ярлык Настройки ввода Использовать сенсор как тачпад Если включено, сенсор выступает в роли тачпада. Если отключено, сенсор напрямую контролирует курсор мыши. - Вы уверены что хотите удалить этот PC? - Детали - Слабое соединение с PC + Вы уверены что хотите удалить этот ПК? + Посмотреть детали + Слабое соединение с ПК Детали Включить отображение статистики Разблокировать все возможные частоты обновления @@ -172,10 +168,11 @@ Вибрировать устройство для эмуляции виброотдачи при экранном управлении Вибрировать устройство для эмуляции виброотдачи для геймпадов без поддержки вибрации Включение этой опции может привести к неправильной работе правой кнопки мыши на некоторых устройствах - PC не найден - Текущий лаунчер не позволяет создавать pinned ярлыки + ПК не найден + Текущий лаунчер не позволяет создавать закреплённые ярлыки. Включить кнопки вперед и назад для мыши - Медленное подключение к PC\nУменьшите битрейт + Медленное подключение к ПК +\nСнизьте битрейт Трансляция со скоростью 90 или 120 кадров в секунду может уменьшить задержку на устройствах высокого класса, но может вызвать задержки или сбой на устройствах без поддержки этого функционала Отображение оверлея на экране с информацией о производительности во время трансляции в режиме реального времени Декодер: %1$s @@ -184,7 +181,7 @@ Отброшеных кадров вашей сетью: %1$.2f%% Среднее время декодирования: %1$.2f ms Увеличение для более плавного видео потока. Уменьшите для лучшей производительности на более слабых устройствах. - Указанный PC недействителен + Указанный ПК недействителен Указанное приложение недействительно % Мбит/с @@ -218,11 +215,11 @@ Стерео 5.1 Объёмный звук 7.1 Объёмный звук - Автоматически + Автоматически (Рекомендовано) Предпочитать HEVC - Минимальная задержка - Баланс - Максимальная плавность (может значительно увеличить задержку) + Предпочтительна наименьшая задержка + Сбалансированно + Предпочтительно наиболее плавное видео (может значительно увеличить задержку) Включить поддержку системного эквалайзера Может сделать движения мыши естественней для удаленного рабочего стола, но несовместимо с большинством игр. Сниженная частота обновления может экономить батарею за счет дополнительной задержки видео @@ -235,9 +232,9 @@ \nПопробуйте выключить HDR, изменить разрешение стрима или разрешение экрана на хост-ПК. Позволяет работать аудио эффектам во время стрима, но может увеличить задержку звука Инструкция по настройке - Посмотреть инструкцию о том как настроить ваш пк для стриминга + Посмотреть инструкцию о том как настроить ваш ПК для стриминга Руководство по устранению неполадок - Укажите как сбалансировать задержку и плавностью видео + Укажите, как сбалансировать задержку и плавность видеосигнала Посмотреть политику конфиденциальности Moonlight Политика конфиденциальности 360p @@ -263,7 +260,7 @@ Принудительное полнодиапазонное видео (экспериментальное) Это приведет к потере детализации в светлых и темных областях, если ваше устройство не отображает видеоконтент в полном диапазоне должным образом. Тип геймпада может быть изменен из-за эмуляции датчика движения - Предпочитать AV1 (экспериментальный) + Предпочитать AV1 (Экспериментальный) Использовать аналоговый джойстик для прокрутки Правый аналоговый джойстик Если на вашем хост-ПК установлен Sunshine, перейдите в веб-интерфейс Sunshine и введите PIN-код. @@ -281,5 +278,5 @@ Выбрать аналоговый джойстик для прокрутки в режиме эмуляции мыши Нет (оба джойстика перемещают мышь) Левый аналоговый джойстик - NVIDIA GameStream Обслуживание окончено + NVIDIA GameStream обслуживание окончено \ No newline at end of file From e1fcb19b23bfe854d7f7e27ba44c86a797d896a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D1=80=D1=82=D1=91=D0=BC=20=D0=96=D1=83=D1=80=D0=B8?= =?UTF-8?q?=D0=BD?= Date: Wed, 3 Jul 2024 15:22:53 +0000 Subject: [PATCH 010/247] Translated using Weblate (Russian) Currently translated at 100.0% (251 of 251 strings) Translation: Moonlight Game Streaming/moonlight-android Translate-URL: https://hosted.weblate.org/projects/moonlight/moonlight-android/ru/ --- app/src/main/res/values-ru/strings.xml | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 1213e408c8..7c9e8dae4b 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -18,7 +18,7 @@ Создание пары… Компьютер выключен или находится не в сети - Компьютер в данный момент находится в игре. Вы должны закрыть игру перед создание пары. + Компьютер в данный момент находится в игре. Вы должны закрыть игру перед созданием пары. Создание пары Пожалуйста введите этот PIN на целевом ПК: Неправильный PIN @@ -49,7 +49,7 @@ Запуск Ошибка соединения Запуск не удался - Соединение прекращено + Соединение разорвано Подключение было прервано. IP-адрес хост-ПК @@ -64,7 +64,7 @@ Выйти из сессии Выйти из текущей игры и запустить Отмена - Добавать на канал + Добавить в канал Список приложений Обновление приложений… Ошибка @@ -72,7 +72,7 @@ Выход из Выход произошёл успешно из Ошибка при выходе - Вы уверены, что хотите выйти из запущенного приложения? Все несохраненные данные будут потеряны. + Вы уверены, что хотите выйти из запущенного приложения? Все несохранённые данные будут потеряны. Добавление ПК вручную Соединение с ПК… @@ -94,7 +94,7 @@ Включить объёмный звук 5.1 или 7.1 для систем домашнего кинотеатра Поддержка нескольких контроллеров Когда отключена, все контроллеры определяются как один - Регулировать мертвую зону аналогового стика + Регулировать мёртвую зону аналогового стика % Драйвер контроллеров Xbox 360/One Включить встроенный USB драйвер для устройств без собственной поддержки контроллеров Xbox @@ -110,7 +110,7 @@ Разрешить GFE изменять настройки игр для оптимальной трансляции Проигрывать звук на ПК Проигрывать звук на компьютере и текущем устройстве - Расширенные Настройки + Расширенные настройки Изменить настройки кодека Новые кодеки могут снизить требования к пропускной способности видео, если ваше устройство их поддерживает. Выбор кодека можно игнорировать, если он не поддерживается ПО хоста или графическим процессором. Настройки экранных кнопок @@ -120,8 +120,8 @@ Скрывать все экранные кнопки кроме L3 и R3 Сделать экранные кнопки более/менее прозрачными Изменить прозрачность - ПК удален - ПК не сопряжен + ПК удалён + ПК не сопряжён Просмотр помощи Загрузка страницы помощи… Сопряжение уже в процессе @@ -142,7 +142,7 @@ Сбросить схему расположения экранных кнопок Возвращает все экранные элементы управления к их расположениям по умолчанию Сбросить схему - Вы уверены что хотите удалить сохранненную схему расположения кнопок? + Вы уверены что хотите удалить сохраненную схему расположения кнопок? Экранные элементы управления возвращены к положениям по умолчанию Никогда не пропускать кадры Может уменьшить микрозависания на некоторых устройствах, но также увеличить задержку @@ -162,7 +162,7 @@ Слабое соединение с ПК Детали Включить отображение статистики - Разблокировать все возможные частоты обновления + Показать неподдерживаемые настройки частоты кадров ID приложения: Эмуляция виброотдачи Вибрировать устройство для эмуляции виброотдачи при экранном управлении @@ -178,7 +178,7 @@ Декодер: %1$s Входящая частота кадров из сети: %1$.2f FPS Частота кадров при рендеринге: %1$.2f FPS - Отброшеных кадров вашей сетью: %1$.2f%% + Кадры, пропущенные вашим сетевым подключением: %1$.2f%% Среднее время декодирования: %1$.2f ms Увеличение для более плавного видео потока. Уменьшите для лучшей производительности на более слабых устройствах. Указанный ПК недействителен @@ -257,7 +257,7 @@ Настройки геймпада Отрегулируйте интенсивность эмулируемой вибрации % - Принудительное полнодиапазонное видео (экспериментальное) + Принудительное полнодиапазонное видео (Экспериментальное) Это приведет к потере детализации в светлых и темных областях, если ваше устройство не отображает видеоконтент в полном диапазоне должным образом. Тип геймпада может быть изменен из-за эмуляции датчика движения Предпочитать AV1 (Экспериментальный) From 030c79fd9f77b534da7501b545de452a586800f5 Mon Sep 17 00:00:00 2001 From: axi Date: Sun, 7 Jul 2024 15:19:27 +0800 Subject: [PATCH 011/247] =?UTF-8?q?feat:=E6=8F=90=E4=BA=A4=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle | 8 +- app/src/main/AndroidManifest.xml | 437 +- app/src/main/assets/config/keyboard.json | 330 + .../main/assets/config/specialbuttons.json | 14 + ...4\346\226\260\350\257\264\346\230\216.txt" | 20 + app/src/main/ic_launcher-web.png | Bin app/src/main/java/com/limelight/AppView.java | 1330 ++-- .../java/com/limelight/AxiTestActivity.java | 306 + app/src/main/java/com/limelight/Game.java | 5742 +++++++------- app/src/main/java/com/limelight/GameMenu.java | 294 + app/src/main/java/com/limelight/GameSbs.java | 2426 ++++++ .../main/java/com/limelight/HelpActivity.java | 232 +- .../KeyboardAccessibilityService.java | 72 + app/src/main/java/com/limelight/LimeLog.java | 50 +- app/src/main/java/com/limelight/PcView.java | 1576 ++-- .../com/limelight/PosterContentProvider.java | 214 +- .../SecondaryDisplayPresentation.java | 45 + .../java/com/limelight/SensitivityBean.java | 48 + .../com/limelight/ShortcutTrampoline.java | 594 +- .../limelight/binding/PlatformBinding.java | 28 +- .../binding/audio/AndroidAudioRenderer.java | 466 +- .../binding/crypto/AndroidCryptoProvider.java | 518 +- .../binding/input/ControllerHandler.java | 6603 +++++++++-------- .../binding/input/GameInputDevice.java | 19 + .../binding/input/KeyboardTranslator.java | 805 +- .../AndroidNativePointerCaptureProvider.java | 340 +- .../AndroidPointerIconCaptureProvider.java | 70 +- .../input/capture/InputCaptureManager.java | 76 +- .../input/capture/InputCaptureProvider.java | 98 +- .../input/capture/NullCaptureProvider.java | 8 +- .../input/capture/ShieldCaptureProvider.java | 186 +- .../input/driver/AbstractController.java | 154 +- .../input/driver/AbstractXboxController.java | 346 +- .../input/driver/UsbDriverListener.java | 22 +- .../input/driver/UsbDriverService.java | 707 +- .../input/driver/Xbox360Controller.java | 331 +- .../input/driver/Xbox360WirelessDongle.java | 296 +- .../input/driver/XboxOneController.java | 454 +- .../input/evdev/EvdevCaptureProviderShim.java | 48 +- .../binding/input/evdev/EvdevListener.java | 30 +- .../input/touch/AbsoluteTouchContext.java | 498 +- .../touch/AbsoluteTouchSwitchContext.java | 249 + .../input/touch/RelativeTouchContext.java | 662 +- .../touch/RelativeTouchSwitchContext.java | 331 + .../binding/input/touch/TouchContext.java | 22 +- .../input/virtual_controller/AnalogStick.java | 698 +- .../virtual_controller/AnalogStickFree.java | 512 ++ .../virtual_controller/DigitalButton.java | 500 +- .../input/virtual_controller/DigitalPad.java | 519 +- .../virtual_controller/LeftAnalogStick.java | 98 +- .../LeftAnalogStickFree.java | 51 + .../input/virtual_controller/LeftTrigger.java | 72 +- .../virtual_controller/RightAnalogStick.java | 98 +- .../RightAnalogStickFree.java | 51 + .../virtual_controller/RightTrigger.java | 72 +- .../virtual_controller/VirtualController.java | 465 +- .../VirtualControllerConfigurationLoader.java | 808 +- .../VirtualControllerElement.java | 706 +- .../keyboard/KeyAnalogStick.java | 351 + .../keyboard/KeyBoardAnalogStickButton.java | 154 + .../KeyBoardAnalogStickButtonFree.java | 154 + .../keyboard/KeyBoardController.java | 219 + ...KeyBoardControllerConfigurationLoader.java | 378 + .../keyboard/KeyBoardDigitalButton.java | 255 + .../keyboard/KeyBoardLayoutController.java | 135 + .../keyboard/KeyBoardTouchPadButton.java | 281 + .../keyboard/KeyboardDigitalPadButton.java | 202 + .../keyboard/keyAnalogStickFree.java | 419 ++ .../keyBoardVirtualControllerElement.java | 363 + .../binding/video/CrashListener.java | 10 +- .../video/MediaCodecDecoderRenderer.java | 3979 +++++----- .../binding/video/MediaCodecHelper.java | 2034 ++--- .../binding/video/PerfOverlayListener.java | 10 +- .../limelight/binding/video/VideoStats.java | 184 +- .../computers/ComputerDatabaseManager.java | 412 +- .../computers/ComputerManagerListener.java | 14 +- .../computers/ComputerManagerService.java | 1936 ++--- .../limelight/computers/IdentityManager.java | 146 +- .../computers/LegacyDatabaseReader.java | 204 +- .../computers/LegacyDatabaseReader2.java | 166 +- .../computers/LegacyDatabaseReader3.java | 246 +- .../limelight/discovery/DiscoveryService.java | 180 +- .../com/limelight/grid/AppGridAdapter.java | 368 +- .../limelight/grid/GenericGridAdapter.java | 148 +- .../com/limelight/grid/PcGridAdapter.java | 186 +- .../grid/assets/CachedAppAssetLoader.java | 792 +- .../grid/assets/DiskAssetLoader.java | 332 +- .../grid/assets/MemoryAssetLoader.java | 148 +- .../grid/assets/NetworkAssetLoader.java | 80 +- .../limelight/grid/assets/ScaledBitmap.java | 36 +- .../limelight/nvstream/ConnectionContext.java | 68 +- .../com/limelight/nvstream/NvConnection.java | 1189 +-- .../nvstream/NvConnectionListener.java | 46 +- .../nvstream/StreamConfiguration.java | 448 +- .../nvstream/av/ByteBufferDescriptor.java | 114 +- .../nvstream/av/audio/AudioRenderer.java | 30 +- .../av/video/VideoDecoderRenderer.java | 42 +- .../nvstream/http/ComputerDetails.java | 336 +- .../http/HostHttpResponseException.java | 56 +- .../http/LimelightCryptoProvider.java | 22 +- .../com/limelight/nvstream/http/NvApp.java | 140 +- .../com/limelight/nvstream/http/NvHTTP.java | 1616 ++-- .../nvstream/http/PairingManager.java | 668 +- .../nvstream/input/ControllerPacket.java | 52 +- .../nvstream/input/KeyboardPacket.java | 20 +- .../nvstream/input/MouseButtonPacket.java | 24 +- .../limelight/nvstream/jni/MoonBridge.java | 842 +-- .../nvstream/mdns/JmDNSDiscoveryAgent.java | 538 +- .../limelight/nvstream/mdns/MdnsComputer.java | 142 +- .../nvstream/mdns/MdnsDiscoveryAgent.java | 296 +- .../nvstream/mdns/MdnsDiscoveryListener.java | 12 +- .../mdns/NsdManagerDiscoveryAgent.java | 468 +- .../nvstream/wol/WakeOnLanSender.java | 298 +- .../preferences/AddComputerManually.java | 646 +- .../ConfirmDeleteKeyboardPreference.java | 43 + .../ConfirmDeleteOscPreference.java | 76 +- .../limelight/preferences/GlPreferences.java | 74 +- .../preferences/LanguagePreference.java | 98 +- .../preferences/PreferenceConfiguration.java | 1340 ++-- .../preferences/SeekBarPreference.java | 386 +- .../SmallIconCheckboxPreference.java | 42 +- .../limelight/preferences/StreamSettings.java | 1560 ++-- .../preferences/WebLauncherPreference.java | 88 +- .../limelight/sbs/TextureSurfaceRenderer.java | 222 + .../limelight/sbs/VideoTextureRenderer.java | 343 + .../com/limelight/ui/AdapterFragment.java | 70 +- .../ui/AdapterFragmentCallbacks.java | 16 +- .../com/limelight/ui/ApertureViewGroup.java | 150 + .../java/com/limelight/ui/GameGestures.java | 14 +- .../java/com/limelight/ui/SBSStreamView.java | 83 + .../java/com/limelight/ui/StreamView.java | 170 +- .../java/com/limelight/utils/CacheHelper.java | 172 +- .../java/com/limelight/utils/DeviceUtils.java | 410 + .../main/java/com/limelight/utils/Dialog.java | 226 +- .../com/limelight/utils/FileUriUtils.java | 102 + .../com/limelight/utils/HelpLauncher.java | 102 +- .../java/com/limelight/utils/NetHelper.java | 64 +- .../com/limelight/utils/ServerHelper.java | 344 +- .../com/limelight/utils/ShortcutHelper.java | 424 +- .../com/limelight/utils/SpinnerDialog.java | 240 +- .../limelight/utils/TrafficStatsHelper.java | 37 + .../com/limelight/utils/TvChannelHelper.java | 732 +- .../java/com/limelight/utils/UiHelper.java | 527 +- .../java/com/limelight/utils/Vector2d.java | 94 +- app/src/main/res/anim/boxart_fadein.xml | 14 +- app/src/main/res/anim/boxart_fadeout.xml | 14 +- .../main/res/drawable-xhdpi/atv_banner.png | Bin .../main/res/drawable-xhdpi/no_app_image.png | Bin app/src/main/res/drawable-xhdpi/ouya_icon.png | Bin app/src/main/res/drawable/app_icon.png | Bin .../res/drawable/bg_ax_keyboard_button.xml | 13 + .../bg_ax_keyboard_button_confirm.xml | 13 + app/src/main/res/drawable/facebutton_a.xml | 22 + .../main/res/drawable/facebutton_a_press.xml | 8 + app/src/main/res/drawable/facebutton_b.xml | 22 + .../main/res/drawable/facebutton_b_press.xml | 8 + app/src/main/res/drawable/facebutton_dpad.xml | 24 + .../main/res/drawable/facebutton_dpad_up.xml | 24 + .../res/drawable/facebutton_dpad_up_right.xml | 24 + app/src/main/res/drawable/facebutton_l.xml | 24 + app/src/main/res/drawable/facebutton_l3.xml | 128 + .../main/res/drawable/facebutton_l3_press.xml | 75 + .../main/res/drawable/facebutton_l_press.xml | 8 + .../main/res/drawable/facebutton_minus.xml | 22 + .../res/drawable/facebutton_minus_press.xml | 9 + app/src/main/res/drawable/facebutton_plus.xml | 22 + .../res/drawable/facebutton_plus_press.xml | 9 + app/src/main/res/drawable/facebutton_r.xml | 23 + app/src/main/res/drawable/facebutton_r3.xml | 128 + .../main/res/drawable/facebutton_r3_press.xml | 75 + .../main/res/drawable/facebutton_r_press.xml | 8 + .../main/res/drawable/facebutton_touchpad.xml | 12 + .../drawable/facebutton_touchpad_press.xml | 12 + app/src/main/res/drawable/facebutton_x.xml | 22 + .../main/res/drawable/facebutton_x_press.xml | 8 + app/src/main/res/drawable/facebutton_y.xml | 22 + .../main/res/drawable/facebutton_y_press.xml | 8 + app/src/main/res/drawable/facebutton_zl.xml | 25 + .../main/res/drawable/facebutton_zl_press.xml | 10 + app/src/main/res/drawable/facebutton_zr.xml | 25 + .../main/res/drawable/facebutton_zr_press.xml | 10 + app/src/main/res/drawable/ic_add.xml | 18 +- app/src/main/res/drawable/ic_channel.xml | 20 +- app/src/main/res/drawable/ic_computer.xml | 18 +- app/src/main/res/drawable/ic_help.xml | 18 +- app/src/main/res/drawable/ic_hud_bg.xml | 15 + .../main/res/drawable/ic_keyboard_setting.xml | 12 + app/src/main/res/drawable/ic_lime_layer.xml | 38 +- app/src/main/res/drawable/ic_lock.xml | 18 +- app/src/main/res/drawable/ic_pc_offline.xml | 18 +- app/src/main/res/drawable/ic_play.xml | 18 +- app/src/main/res/drawable/ic_settings.xml | 18 +- .../res/drawable/list_view_unselected.xml | 12 +- .../main/res/layout-land/activity_pc_view.xml | 168 +- .../layout/activity_add_computer_manually.xml | 102 +- app/src/main/res/layout/activity_app_view.xml | 60 +- app/src/main/res/layout/activity_axitest.xml | 72 + .../activity_configure_virtual_controller.xml | 14 +- app/src/main/res/layout/activity_game.xml | 144 +- .../main/res/layout/activity_game_display.xml | 8 + app/src/main/res/layout/activity_gamevr.xml | 125 + app/src/main/res/layout/activity_pc_view.xml | 178 +- .../res/layout/activity_stream_settings.xml | 14 +- app/src/main/res/layout/app_grid_item.xml | 56 +- .../main/res/layout/app_grid_item_small.xml | 56 +- app/src/main/res/layout/app_grid_view.xml | 22 +- .../main/res/layout/app_grid_view_small.xml | 22 +- .../main/res/layout/layout_axixi_keyboard.xml | 875 +++ app/src/main/res/layout/pc_grid_item.xml | 82 +- app/src/main/res/layout/pc_grid_view.xml | 24 +- .../res/mipmap-anydpi-v26/ic_launcher.xml | 10 +- .../main/res/mipmap-anydpi-v26/ic_pc_scut.xml | 10 +- app/src/main/res/mipmap-hdpi/ic_launcher.png | Bin .../mipmap-hdpi/ic_launcher_foreground.png | Bin app/src/main/res/mipmap-hdpi/ic_pc_scut.png | Bin .../res/mipmap-hdpi/ic_pc_scut_foreground.png | Bin app/src/main/res/mipmap-mdpi/ic_launcher.png | Bin .../mipmap-mdpi/ic_launcher_foreground.png | Bin app/src/main/res/mipmap-mdpi/ic_pc_scut.png | Bin .../res/mipmap-mdpi/ic_pc_scut_foreground.png | Bin app/src/main/res/mipmap-xhdpi/ic_launcher.png | Bin .../mipmap-xhdpi/ic_launcher_foreground.png | Bin app/src/main/res/mipmap-xhdpi/ic_pc_scut.png | Bin .../mipmap-xhdpi/ic_pc_scut_foreground.png | Bin .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin .../mipmap-xxhdpi/ic_launcher_foreground.png | Bin app/src/main/res/mipmap-xxhdpi/ic_pc_scut.png | Bin .../mipmap-xxhdpi/ic_pc_scut_foreground.png | Bin .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin .../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin .../main/res/mipmap-xxxhdpi/ic_pc_scut.png | Bin .../mipmap-xxxhdpi/ic_pc_scut_foreground.png | Bin app/src/main/res/values-bg/strings.xml | 284 +- app/src/main/res/values-ckb/strings.xml | 2 +- app/src/main/res/values-cs/strings.xml | 498 +- app/src/main/res/values-de/strings.xml | 554 +- app/src/main/res/values-el/strings.xml | 428 +- app/src/main/res/values-es/strings.xml | 578 +- app/src/main/res/values-fa/strings.xml | 2 +- app/src/main/res/values-fr/strings.xml | 574 +- app/src/main/res/values-hu/strings.xml | 522 +- app/src/main/res/values-in/strings.xml | 426 +- app/src/main/res/values-it/strings.xml | 562 +- app/src/main/res/values-iw/strings.xml | 308 +- app/src/main/res/values-ja/strings.xml | 278 +- app/src/main/res/values-ko/strings.xml | 560 +- app/src/main/res/values-nb-rNO/strings.xml | 418 +- app/src/main/res/values-nl/strings.xml | 450 +- app/src/main/res/values-pl/strings.xml | 548 +- app/src/main/res/values-pt-rBR/strings.xml | 512 +- app/src/main/res/values-pt/strings.xml | 512 +- app/src/main/res/values-ro/strings.xml | 360 +- app/src/main/res/values-ru/strings.xml | 512 +- app/src/main/res/values-sv/strings.xml | 548 +- app/src/main/res/values-tr/strings.xml | 350 +- app/src/main/res/values-uk/strings.xml | 570 +- app/src/main/res/values-v14/styles.xml | 24 +- app/src/main/res/values-v21/styles.xml | 28 +- app/src/main/res/values-v24/styles.xml | 24 +- app/src/main/res/values-v29/styles.xml | 14 +- app/src/main/res/values-vi/strings.xml | 470 +- app/src/main/res/values-zh-rCN/strings.xml | 572 +- app/src/main/res/values-zh-rTW/strings.xml | 556 +- app/src/main/res/values/arrays.xml | 301 +- app/src/main/res/values/dimens.xml | 18 +- .../res/values/ic_launcher_background.xml | 6 +- .../main/res/values/ic_pc_scut_background.xml | 6 +- app/src/main/res/values/strings.xml | 653 +- app/src/main/res/values/styles.xml | 9 + app/src/main/res/xml/backup_rules.xml | 8 +- app/src/main/res/xml/backup_rules_s.xml | 18 +- app/src/main/res/xml/game_mode_config.xml | 14 +- .../xml/keyboard_accessibility_service.xml | 12 + app/src/main/res/xml/locales_config.xml | 50 +- .../main/res/xml/network_security_config.xml | 14 +- app/src/main/res/xml/preferences.xml | 316 +- app/src/main/res/xml/provider_file_paths.xml | 18 + app/src/nonRoot/AndroidManifest.xml | 14 +- app/src/root/AndroidManifest.xml | 20 +- .../input/evdev/EvdevCaptureProvider.java | 694 +- .../binding/input/evdev/EvdevEvent.java | 76 +- .../binding/input/evdev/EvdevReader.java | 116 +- .../binding/input/evdev/EvdevTranslator.java | 278 +- 283 files changed, 46586 insertions(+), 33874 deletions(-) mode change 100644 => 100755 app/src/main/AndroidManifest.xml create mode 100755 app/src/main/assets/config/keyboard.json create mode 100755 app/src/main/assets/config/specialbuttons.json create mode 100755 "app/src/main/assets/config/\346\234\210\345\205\211\351\230\277\350\245\277\350\245\277\346\233\264\346\226\260\350\257\264\346\230\216.txt" mode change 100644 => 100755 app/src/main/ic_launcher-web.png mode change 100644 => 100755 app/src/main/java/com/limelight/AppView.java create mode 100755 app/src/main/java/com/limelight/AxiTestActivity.java mode change 100644 => 100755 app/src/main/java/com/limelight/Game.java create mode 100755 app/src/main/java/com/limelight/GameMenu.java create mode 100755 app/src/main/java/com/limelight/GameSbs.java mode change 100644 => 100755 app/src/main/java/com/limelight/HelpActivity.java create mode 100755 app/src/main/java/com/limelight/KeyboardAccessibilityService.java mode change 100644 => 100755 app/src/main/java/com/limelight/LimeLog.java mode change 100644 => 100755 app/src/main/java/com/limelight/PcView.java mode change 100644 => 100755 app/src/main/java/com/limelight/PosterContentProvider.java create mode 100755 app/src/main/java/com/limelight/SecondaryDisplayPresentation.java create mode 100755 app/src/main/java/com/limelight/SensitivityBean.java mode change 100644 => 100755 app/src/main/java/com/limelight/ShortcutTrampoline.java mode change 100644 => 100755 app/src/main/java/com/limelight/binding/PlatformBinding.java mode change 100644 => 100755 app/src/main/java/com/limelight/binding/audio/AndroidAudioRenderer.java mode change 100644 => 100755 app/src/main/java/com/limelight/binding/crypto/AndroidCryptoProvider.java mode change 100644 => 100755 app/src/main/java/com/limelight/binding/input/ControllerHandler.java create mode 100755 app/src/main/java/com/limelight/binding/input/GameInputDevice.java mode change 100644 => 100755 app/src/main/java/com/limelight/binding/input/KeyboardTranslator.java mode change 100644 => 100755 app/src/main/java/com/limelight/binding/input/capture/AndroidNativePointerCaptureProvider.java mode change 100644 => 100755 app/src/main/java/com/limelight/binding/input/capture/AndroidPointerIconCaptureProvider.java mode change 100644 => 100755 app/src/main/java/com/limelight/binding/input/capture/InputCaptureManager.java mode change 100644 => 100755 app/src/main/java/com/limelight/binding/input/capture/InputCaptureProvider.java mode change 100644 => 100755 app/src/main/java/com/limelight/binding/input/capture/NullCaptureProvider.java mode change 100644 => 100755 app/src/main/java/com/limelight/binding/input/capture/ShieldCaptureProvider.java mode change 100644 => 100755 app/src/main/java/com/limelight/binding/input/driver/AbstractController.java mode change 100644 => 100755 app/src/main/java/com/limelight/binding/input/driver/AbstractXboxController.java mode change 100644 => 100755 app/src/main/java/com/limelight/binding/input/driver/UsbDriverListener.java mode change 100644 => 100755 app/src/main/java/com/limelight/binding/input/driver/UsbDriverService.java mode change 100644 => 100755 app/src/main/java/com/limelight/binding/input/driver/Xbox360Controller.java mode change 100644 => 100755 app/src/main/java/com/limelight/binding/input/driver/Xbox360WirelessDongle.java mode change 100644 => 100755 app/src/main/java/com/limelight/binding/input/driver/XboxOneController.java mode change 100644 => 100755 app/src/main/java/com/limelight/binding/input/evdev/EvdevCaptureProviderShim.java mode change 100644 => 100755 app/src/main/java/com/limelight/binding/input/evdev/EvdevListener.java mode change 100644 => 100755 app/src/main/java/com/limelight/binding/input/touch/AbsoluteTouchContext.java create mode 100755 app/src/main/java/com/limelight/binding/input/touch/AbsoluteTouchSwitchContext.java mode change 100644 => 100755 app/src/main/java/com/limelight/binding/input/touch/RelativeTouchContext.java create mode 100755 app/src/main/java/com/limelight/binding/input/touch/RelativeTouchSwitchContext.java mode change 100644 => 100755 app/src/main/java/com/limelight/binding/input/touch/TouchContext.java mode change 100644 => 100755 app/src/main/java/com/limelight/binding/input/virtual_controller/AnalogStick.java create mode 100755 app/src/main/java/com/limelight/binding/input/virtual_controller/AnalogStickFree.java mode change 100644 => 100755 app/src/main/java/com/limelight/binding/input/virtual_controller/DigitalButton.java mode change 100644 => 100755 app/src/main/java/com/limelight/binding/input/virtual_controller/DigitalPad.java mode change 100644 => 100755 app/src/main/java/com/limelight/binding/input/virtual_controller/LeftAnalogStick.java create mode 100755 app/src/main/java/com/limelight/binding/input/virtual_controller/LeftAnalogStickFree.java mode change 100644 => 100755 app/src/main/java/com/limelight/binding/input/virtual_controller/LeftTrigger.java mode change 100644 => 100755 app/src/main/java/com/limelight/binding/input/virtual_controller/RightAnalogStick.java create mode 100755 app/src/main/java/com/limelight/binding/input/virtual_controller/RightAnalogStickFree.java mode change 100644 => 100755 app/src/main/java/com/limelight/binding/input/virtual_controller/RightTrigger.java mode change 100644 => 100755 app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualController.java mode change 100644 => 100755 app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualControllerConfigurationLoader.java mode change 100644 => 100755 app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualControllerElement.java create mode 100755 app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyAnalogStick.java create mode 100755 app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardAnalogStickButton.java create mode 100755 app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardAnalogStickButtonFree.java create mode 100755 app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardController.java create mode 100755 app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardControllerConfigurationLoader.java create mode 100755 app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardDigitalButton.java create mode 100755 app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardLayoutController.java create mode 100755 app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardTouchPadButton.java create mode 100755 app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyboardDigitalPadButton.java create mode 100755 app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/keyAnalogStickFree.java create mode 100755 app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/keyBoardVirtualControllerElement.java mode change 100644 => 100755 app/src/main/java/com/limelight/binding/video/CrashListener.java mode change 100644 => 100755 app/src/main/java/com/limelight/binding/video/MediaCodecDecoderRenderer.java mode change 100644 => 100755 app/src/main/java/com/limelight/binding/video/MediaCodecHelper.java mode change 100644 => 100755 app/src/main/java/com/limelight/binding/video/PerfOverlayListener.java mode change 100644 => 100755 app/src/main/java/com/limelight/binding/video/VideoStats.java mode change 100644 => 100755 app/src/main/java/com/limelight/computers/ComputerDatabaseManager.java mode change 100644 => 100755 app/src/main/java/com/limelight/computers/ComputerManagerListener.java mode change 100644 => 100755 app/src/main/java/com/limelight/computers/ComputerManagerService.java mode change 100644 => 100755 app/src/main/java/com/limelight/computers/IdentityManager.java mode change 100644 => 100755 app/src/main/java/com/limelight/computers/LegacyDatabaseReader.java mode change 100644 => 100755 app/src/main/java/com/limelight/computers/LegacyDatabaseReader2.java mode change 100644 => 100755 app/src/main/java/com/limelight/computers/LegacyDatabaseReader3.java mode change 100644 => 100755 app/src/main/java/com/limelight/discovery/DiscoveryService.java mode change 100644 => 100755 app/src/main/java/com/limelight/grid/AppGridAdapter.java mode change 100644 => 100755 app/src/main/java/com/limelight/grid/GenericGridAdapter.java mode change 100644 => 100755 app/src/main/java/com/limelight/grid/PcGridAdapter.java mode change 100644 => 100755 app/src/main/java/com/limelight/grid/assets/CachedAppAssetLoader.java mode change 100644 => 100755 app/src/main/java/com/limelight/grid/assets/DiskAssetLoader.java mode change 100644 => 100755 app/src/main/java/com/limelight/grid/assets/MemoryAssetLoader.java mode change 100644 => 100755 app/src/main/java/com/limelight/grid/assets/NetworkAssetLoader.java mode change 100644 => 100755 app/src/main/java/com/limelight/grid/assets/ScaledBitmap.java mode change 100644 => 100755 app/src/main/java/com/limelight/nvstream/ConnectionContext.java mode change 100644 => 100755 app/src/main/java/com/limelight/nvstream/NvConnection.java mode change 100644 => 100755 app/src/main/java/com/limelight/nvstream/NvConnectionListener.java mode change 100644 => 100755 app/src/main/java/com/limelight/nvstream/StreamConfiguration.java mode change 100644 => 100755 app/src/main/java/com/limelight/nvstream/av/ByteBufferDescriptor.java mode change 100644 => 100755 app/src/main/java/com/limelight/nvstream/av/audio/AudioRenderer.java mode change 100644 => 100755 app/src/main/java/com/limelight/nvstream/av/video/VideoDecoderRenderer.java mode change 100644 => 100755 app/src/main/java/com/limelight/nvstream/http/ComputerDetails.java mode change 100644 => 100755 app/src/main/java/com/limelight/nvstream/http/HostHttpResponseException.java mode change 100644 => 100755 app/src/main/java/com/limelight/nvstream/http/LimelightCryptoProvider.java mode change 100644 => 100755 app/src/main/java/com/limelight/nvstream/http/NvApp.java mode change 100644 => 100755 app/src/main/java/com/limelight/nvstream/http/NvHTTP.java mode change 100644 => 100755 app/src/main/java/com/limelight/nvstream/http/PairingManager.java mode change 100644 => 100755 app/src/main/java/com/limelight/nvstream/input/ControllerPacket.java mode change 100644 => 100755 app/src/main/java/com/limelight/nvstream/input/KeyboardPacket.java mode change 100644 => 100755 app/src/main/java/com/limelight/nvstream/input/MouseButtonPacket.java mode change 100644 => 100755 app/src/main/java/com/limelight/nvstream/jni/MoonBridge.java mode change 100644 => 100755 app/src/main/java/com/limelight/nvstream/mdns/JmDNSDiscoveryAgent.java mode change 100644 => 100755 app/src/main/java/com/limelight/nvstream/mdns/MdnsComputer.java mode change 100644 => 100755 app/src/main/java/com/limelight/nvstream/mdns/MdnsDiscoveryAgent.java mode change 100644 => 100755 app/src/main/java/com/limelight/nvstream/mdns/MdnsDiscoveryListener.java mode change 100644 => 100755 app/src/main/java/com/limelight/nvstream/mdns/NsdManagerDiscoveryAgent.java mode change 100644 => 100755 app/src/main/java/com/limelight/nvstream/wol/WakeOnLanSender.java mode change 100644 => 100755 app/src/main/java/com/limelight/preferences/AddComputerManually.java create mode 100755 app/src/main/java/com/limelight/preferences/ConfirmDeleteKeyboardPreference.java mode change 100644 => 100755 app/src/main/java/com/limelight/preferences/ConfirmDeleteOscPreference.java mode change 100644 => 100755 app/src/main/java/com/limelight/preferences/GlPreferences.java mode change 100644 => 100755 app/src/main/java/com/limelight/preferences/LanguagePreference.java mode change 100644 => 100755 app/src/main/java/com/limelight/preferences/PreferenceConfiguration.java mode change 100644 => 100755 app/src/main/java/com/limelight/preferences/SeekBarPreference.java mode change 100644 => 100755 app/src/main/java/com/limelight/preferences/SmallIconCheckboxPreference.java mode change 100644 => 100755 app/src/main/java/com/limelight/preferences/StreamSettings.java mode change 100644 => 100755 app/src/main/java/com/limelight/preferences/WebLauncherPreference.java create mode 100755 app/src/main/java/com/limelight/sbs/TextureSurfaceRenderer.java create mode 100755 app/src/main/java/com/limelight/sbs/VideoTextureRenderer.java mode change 100644 => 100755 app/src/main/java/com/limelight/ui/AdapterFragment.java mode change 100644 => 100755 app/src/main/java/com/limelight/ui/AdapterFragmentCallbacks.java create mode 100755 app/src/main/java/com/limelight/ui/ApertureViewGroup.java mode change 100644 => 100755 app/src/main/java/com/limelight/ui/GameGestures.java create mode 100755 app/src/main/java/com/limelight/ui/SBSStreamView.java mode change 100644 => 100755 app/src/main/java/com/limelight/ui/StreamView.java mode change 100644 => 100755 app/src/main/java/com/limelight/utils/CacheHelper.java create mode 100755 app/src/main/java/com/limelight/utils/DeviceUtils.java mode change 100644 => 100755 app/src/main/java/com/limelight/utils/Dialog.java create mode 100755 app/src/main/java/com/limelight/utils/FileUriUtils.java mode change 100644 => 100755 app/src/main/java/com/limelight/utils/HelpLauncher.java mode change 100644 => 100755 app/src/main/java/com/limelight/utils/NetHelper.java mode change 100644 => 100755 app/src/main/java/com/limelight/utils/ServerHelper.java mode change 100644 => 100755 app/src/main/java/com/limelight/utils/ShortcutHelper.java mode change 100644 => 100755 app/src/main/java/com/limelight/utils/SpinnerDialog.java create mode 100755 app/src/main/java/com/limelight/utils/TrafficStatsHelper.java mode change 100644 => 100755 app/src/main/java/com/limelight/utils/TvChannelHelper.java mode change 100644 => 100755 app/src/main/java/com/limelight/utils/UiHelper.java mode change 100644 => 100755 app/src/main/java/com/limelight/utils/Vector2d.java mode change 100644 => 100755 app/src/main/res/anim/boxart_fadein.xml mode change 100644 => 100755 app/src/main/res/anim/boxart_fadeout.xml mode change 100644 => 100755 app/src/main/res/drawable-xhdpi/atv_banner.png mode change 100644 => 100755 app/src/main/res/drawable-xhdpi/no_app_image.png mode change 100644 => 100755 app/src/main/res/drawable-xhdpi/ouya_icon.png mode change 100644 => 100755 app/src/main/res/drawable/app_icon.png create mode 100755 app/src/main/res/drawable/bg_ax_keyboard_button.xml create mode 100755 app/src/main/res/drawable/bg_ax_keyboard_button_confirm.xml create mode 100755 app/src/main/res/drawable/facebutton_a.xml create mode 100755 app/src/main/res/drawable/facebutton_a_press.xml create mode 100755 app/src/main/res/drawable/facebutton_b.xml create mode 100755 app/src/main/res/drawable/facebutton_b_press.xml create mode 100755 app/src/main/res/drawable/facebutton_dpad.xml create mode 100755 app/src/main/res/drawable/facebutton_dpad_up.xml create mode 100755 app/src/main/res/drawable/facebutton_dpad_up_right.xml create mode 100755 app/src/main/res/drawable/facebutton_l.xml create mode 100755 app/src/main/res/drawable/facebutton_l3.xml create mode 100755 app/src/main/res/drawable/facebutton_l3_press.xml create mode 100755 app/src/main/res/drawable/facebutton_l_press.xml create mode 100755 app/src/main/res/drawable/facebutton_minus.xml create mode 100755 app/src/main/res/drawable/facebutton_minus_press.xml create mode 100755 app/src/main/res/drawable/facebutton_plus.xml create mode 100755 app/src/main/res/drawable/facebutton_plus_press.xml create mode 100755 app/src/main/res/drawable/facebutton_r.xml create mode 100755 app/src/main/res/drawable/facebutton_r3.xml create mode 100755 app/src/main/res/drawable/facebutton_r3_press.xml create mode 100755 app/src/main/res/drawable/facebutton_r_press.xml create mode 100755 app/src/main/res/drawable/facebutton_touchpad.xml create mode 100755 app/src/main/res/drawable/facebutton_touchpad_press.xml create mode 100755 app/src/main/res/drawable/facebutton_x.xml create mode 100755 app/src/main/res/drawable/facebutton_x_press.xml create mode 100755 app/src/main/res/drawable/facebutton_y.xml create mode 100755 app/src/main/res/drawable/facebutton_y_press.xml create mode 100755 app/src/main/res/drawable/facebutton_zl.xml create mode 100755 app/src/main/res/drawable/facebutton_zl_press.xml create mode 100755 app/src/main/res/drawable/facebutton_zr.xml create mode 100755 app/src/main/res/drawable/facebutton_zr_press.xml mode change 100644 => 100755 app/src/main/res/drawable/ic_add.xml mode change 100644 => 100755 app/src/main/res/drawable/ic_channel.xml mode change 100644 => 100755 app/src/main/res/drawable/ic_computer.xml mode change 100644 => 100755 app/src/main/res/drawable/ic_help.xml create mode 100755 app/src/main/res/drawable/ic_hud_bg.xml create mode 100755 app/src/main/res/drawable/ic_keyboard_setting.xml mode change 100644 => 100755 app/src/main/res/drawable/ic_lime_layer.xml mode change 100644 => 100755 app/src/main/res/drawable/ic_lock.xml mode change 100644 => 100755 app/src/main/res/drawable/ic_pc_offline.xml mode change 100644 => 100755 app/src/main/res/drawable/ic_play.xml mode change 100644 => 100755 app/src/main/res/drawable/ic_settings.xml mode change 100644 => 100755 app/src/main/res/drawable/list_view_unselected.xml mode change 100644 => 100755 app/src/main/res/layout-land/activity_pc_view.xml mode change 100644 => 100755 app/src/main/res/layout/activity_add_computer_manually.xml mode change 100644 => 100755 app/src/main/res/layout/activity_app_view.xml create mode 100755 app/src/main/res/layout/activity_axitest.xml mode change 100644 => 100755 app/src/main/res/layout/activity_configure_virtual_controller.xml mode change 100644 => 100755 app/src/main/res/layout/activity_game.xml create mode 100755 app/src/main/res/layout/activity_game_display.xml create mode 100755 app/src/main/res/layout/activity_gamevr.xml mode change 100644 => 100755 app/src/main/res/layout/activity_pc_view.xml mode change 100644 => 100755 app/src/main/res/layout/activity_stream_settings.xml mode change 100644 => 100755 app/src/main/res/layout/app_grid_item.xml mode change 100644 => 100755 app/src/main/res/layout/app_grid_item_small.xml mode change 100644 => 100755 app/src/main/res/layout/app_grid_view.xml mode change 100644 => 100755 app/src/main/res/layout/app_grid_view_small.xml create mode 100755 app/src/main/res/layout/layout_axixi_keyboard.xml mode change 100644 => 100755 app/src/main/res/layout/pc_grid_item.xml mode change 100644 => 100755 app/src/main/res/layout/pc_grid_view.xml mode change 100644 => 100755 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml mode change 100644 => 100755 app/src/main/res/mipmap-anydpi-v26/ic_pc_scut.xml mode change 100644 => 100755 app/src/main/res/mipmap-hdpi/ic_launcher.png mode change 100644 => 100755 app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png mode change 100644 => 100755 app/src/main/res/mipmap-hdpi/ic_pc_scut.png mode change 100644 => 100755 app/src/main/res/mipmap-hdpi/ic_pc_scut_foreground.png mode change 100644 => 100755 app/src/main/res/mipmap-mdpi/ic_launcher.png mode change 100644 => 100755 app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png mode change 100644 => 100755 app/src/main/res/mipmap-mdpi/ic_pc_scut.png mode change 100644 => 100755 app/src/main/res/mipmap-mdpi/ic_pc_scut_foreground.png mode change 100644 => 100755 app/src/main/res/mipmap-xhdpi/ic_launcher.png mode change 100644 => 100755 app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png mode change 100644 => 100755 app/src/main/res/mipmap-xhdpi/ic_pc_scut.png mode change 100644 => 100755 app/src/main/res/mipmap-xhdpi/ic_pc_scut_foreground.png mode change 100644 => 100755 app/src/main/res/mipmap-xxhdpi/ic_launcher.png mode change 100644 => 100755 app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png mode change 100644 => 100755 app/src/main/res/mipmap-xxhdpi/ic_pc_scut.png mode change 100644 => 100755 app/src/main/res/mipmap-xxhdpi/ic_pc_scut_foreground.png mode change 100644 => 100755 app/src/main/res/mipmap-xxxhdpi/ic_launcher.png mode change 100644 => 100755 app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png mode change 100644 => 100755 app/src/main/res/mipmap-xxxhdpi/ic_pc_scut.png mode change 100644 => 100755 app/src/main/res/mipmap-xxxhdpi/ic_pc_scut_foreground.png mode change 100644 => 100755 app/src/main/res/values-bg/strings.xml mode change 100644 => 100755 app/src/main/res/values-ckb/strings.xml mode change 100644 => 100755 app/src/main/res/values-cs/strings.xml mode change 100644 => 100755 app/src/main/res/values-de/strings.xml mode change 100644 => 100755 app/src/main/res/values-el/strings.xml mode change 100644 => 100755 app/src/main/res/values-es/strings.xml mode change 100644 => 100755 app/src/main/res/values-fa/strings.xml mode change 100644 => 100755 app/src/main/res/values-fr/strings.xml mode change 100644 => 100755 app/src/main/res/values-hu/strings.xml mode change 100644 => 100755 app/src/main/res/values-in/strings.xml mode change 100644 => 100755 app/src/main/res/values-it/strings.xml mode change 100644 => 100755 app/src/main/res/values-iw/strings.xml mode change 100644 => 100755 app/src/main/res/values-ja/strings.xml mode change 100644 => 100755 app/src/main/res/values-ko/strings.xml mode change 100644 => 100755 app/src/main/res/values-nb-rNO/strings.xml mode change 100644 => 100755 app/src/main/res/values-nl/strings.xml mode change 100644 => 100755 app/src/main/res/values-pl/strings.xml mode change 100644 => 100755 app/src/main/res/values-pt-rBR/strings.xml mode change 100644 => 100755 app/src/main/res/values-pt/strings.xml mode change 100644 => 100755 app/src/main/res/values-ro/strings.xml mode change 100644 => 100755 app/src/main/res/values-ru/strings.xml mode change 100644 => 100755 app/src/main/res/values-sv/strings.xml mode change 100644 => 100755 app/src/main/res/values-tr/strings.xml mode change 100644 => 100755 app/src/main/res/values-uk/strings.xml mode change 100644 => 100755 app/src/main/res/values-v14/styles.xml mode change 100644 => 100755 app/src/main/res/values-v21/styles.xml mode change 100644 => 100755 app/src/main/res/values-v24/styles.xml mode change 100644 => 100755 app/src/main/res/values-v29/styles.xml mode change 100644 => 100755 app/src/main/res/values-vi/strings.xml mode change 100644 => 100755 app/src/main/res/values-zh-rCN/strings.xml mode change 100644 => 100755 app/src/main/res/values-zh-rTW/strings.xml mode change 100644 => 100755 app/src/main/res/values/arrays.xml mode change 100644 => 100755 app/src/main/res/values/dimens.xml mode change 100644 => 100755 app/src/main/res/values/ic_launcher_background.xml mode change 100644 => 100755 app/src/main/res/values/ic_pc_scut_background.xml mode change 100644 => 100755 app/src/main/res/values/strings.xml mode change 100644 => 100755 app/src/main/res/values/styles.xml mode change 100644 => 100755 app/src/main/res/xml/backup_rules.xml mode change 100644 => 100755 app/src/main/res/xml/backup_rules_s.xml mode change 100644 => 100755 app/src/main/res/xml/game_mode_config.xml create mode 100755 app/src/main/res/xml/keyboard_accessibility_service.xml mode change 100644 => 100755 app/src/main/res/xml/locales_config.xml mode change 100644 => 100755 app/src/main/res/xml/network_security_config.xml mode change 100644 => 100755 app/src/main/res/xml/preferences.xml create mode 100755 app/src/main/res/xml/provider_file_paths.xml mode change 100644 => 100755 app/src/nonRoot/AndroidManifest.xml mode change 100644 => 100755 app/src/root/AndroidManifest.xml mode change 100644 => 100755 app/src/root/java/com.limelight/binding/input/evdev/EvdevCaptureProvider.java mode change 100644 => 100755 app/src/root/java/com.limelight/binding/input/evdev/EvdevEvent.java mode change 100644 => 100755 app/src/root/java/com.limelight/binding/input/evdev/EvdevReader.java mode change 100644 => 100755 app/src/root/java/com.limelight/binding/input/evdev/EvdevTranslator.java diff --git a/app/build.gradle b/app/build.gradle index e0ea8f6f43..dbe2624b6e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -11,7 +11,7 @@ android { minSdk 21 targetSdk 34 - versionName "12.1" + versionName "12.1-240707-axi" versionCode = 314 // Generate native debug symbols to allow Google Play to symbolicate our native crashes @@ -119,8 +119,8 @@ android { // is to please change the applicationId before you publish. // // TL;DR: Leave the following line alone! - applicationIdSuffix ".unofficial" - resValue "string", "app_label", "Moonlight" + applicationIdSuffix ".unofficialA" + resValue "string", "app_label", "月光·阿西西" resValue "string", "app_label_root", "Moonlight (Root)" minifyEnabled true @@ -142,4 +142,6 @@ dependencies { implementation 'com.squareup.okhttp3:okhttp:4.12.0' implementation 'org.jmdns:jmdns:3.5.9' implementation 'com.github.cgutman:ShieldControllerExtensions:1.0.1' + implementation 'com.google.code.gson:gson:2.10.1' + implementation 'com.android.support:appcompat-v7:28.0.0' } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml old mode 100644 new mode 100755 index 7be9a20bf1..e8e699cecc --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,190 +1,249 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/assets/config/keyboard.json b/app/src/main/assets/config/keyboard.json new file mode 100755 index 0000000000..504a2e672c --- /dev/null +++ b/app/src/main/assets/config/keyboard.json @@ -0,0 +1,330 @@ +{ + "desc": "rocker=摇杆|dpad=十字键|keystroke=普通按钮", + "data": { + "rocker": [ + { + "name": "摇杆", + "elementId": "rocker_1", + "leftCode": 29, + "rightCode": 32, + "upCode": 51, + "downCode": 47, + "middleCode": 59, + "desc":"简单描述下,上下左右中按键code值为keycode查阅附赠的keycode-android.java文件获取,elementId值是唯一的,middleCode是中键的小圆点,双击可以触发" + } + ], + "dpad": [ + { + "name": "十字键", + "elementId": "dpad_1", + "leftCode": 29, + "rightCode": 32, + "upCode": 51, + "downCode": 47, + "desc":"简单描述下,上下左右按键code值为keycode查阅附赠的keycode-android.java文件获取,elementId值是唯一的" + } + ], + "mouse": [ + { + "name": "ML", + "code": 1, + "desc":"简单描述下,code值固定,name为按钮显示文字,ML左键、MM中键、MR右键" + }, + { + "name": "MR", + "code": 3 + }, + { + "name": "MR+", + "code": 3, + "switchButton": 1 + }, + { + "name": "MM", + "code": 2 + }, + { + "name": "触控板(右)", + "code": 9, + "desc": "特殊触控板按键适合魔兽转视野,name的数值可以清空,但是怕不好区分,可以修改成其他的" + }, + { + "name": "触控板", + "code": 10, + "desc": "普通触控板控件" + }, + { + "name": "触控板(左)", + "code": 11, + "desc": "一直按着左键不放~" + } + + ], + "keystroke": [ + { + "name": "Esc", + "code": 111 + }, + { + "name": "F1", + "code": 131 + }, + { + "name": "F2", + "code": 132 + }, + { + "name": "F3", + "code": 133 + }, + { + "name": "F4", + "code": 134 + }, + { + "name": "F5", + "code": 135 + }, + { + "name": "F6", + "code": 136 + }, + { + "name": "F7", + "code": 137 + }, + { + "name": "F8", + "code": 138 + }, + { + "name": "F9", + "code": 139 + }, + { + "name": "F10", + "code": 140 + }, + { + "name": "F11", + "code": 141 + }, + { + "name": "F12", + "code": 142 + }, + { + "name": "Del", + "code": 112 + }, + { + "name": "Home", + "code": 122 + }, + { + "name": "End", + "code": 123 + }, + { + "name": "PgUp", + "code": 92 + }, + { + "name": "PgDn", + "code": 93 + }, + { + "name": "1", + "code": 8 + }, + { + "name": "2", + "code": 9 + }, + { + "name": "3", + "code": 10 + }, + { + "name": "4", + "code": 11 + }, + { + "name": "5", + "code": 12 + }, + { + "name": "6", + "code": 13 + }, + { + "name": "7", + "code": 14 + }, + { + "name": "8", + "code": 15 + }, + { + "name": "9", + "code": 16 + }, + { + "name": "0", + "code": 7 + }, + { + "name": "Ctrl", + "code": 113 + }, + { + "name": "Win", + "code": 117 + }, + { + "name": "Alt", + "code": 57 + }, + { + "name": "Space", + "code": 62 + }, + { + "name": "Shift", + "code": 59 + }, + { + "name": "Tab", + "code": 61 + }, + { + "name": "Enter", + "code": 66 + }, + { + "name": "A", + "code": 29 + }, + { + "name": "B", + "code": 30 + }, + { + "name": "C", + "code": 31 + }, + { + "name": "D", + "code": 32 + }, + { + "name": "E", + "code": 33 + }, + { + "name": "F", + "code": 34 + }, + { + "name": "G", + "code": 35 + }, + { + "name": "H", + "code": 36 + }, + { + "name": "I", + "code": 37 + }, + { + "name": "J", + "code": 38 + }, + { + "name": "K", + "code": 39 + }, + { + "name": "L", + "code": 40 + }, + { + "name": "M", + "code": 41 + }, + { + "name": "N", + "code": 42 + }, + { + "name": "O", + "code": 43 + }, + { + "name": "P", + "code": 44 + }, + { + "name": "Q", + "code": 45 + }, + { + "name": "R", + "code": 46 + }, + { + "name": "S", + "code": 47 + }, + { + "name": "T", + "code": 48 + }, + { + "name": "U", + "code": 49 + }, + { + "name": "V", + "code": 50 + }, + { + "name": "W", + "code": 51 + }, + { + "name": "X", + "code": 52 + }, + { + "name": "Y", + "code": 53 + }, + { + "name": "Z", + "code": 54 + }, + { + "name": "Insert", + "code": 124 + }, + { + "name": "↑", + "code": 19 + }, + { + "name": "↓", + "code": 20 + }, + { + "name": "←", + "code": 21 + }, + { + "name": "→", + "code": 22 + } + ] + } +} \ No newline at end of file diff --git a/app/src/main/assets/config/specialbuttons.json b/app/src/main/assets/config/specialbuttons.json new file mode 100755 index 0000000000..042202a42f --- /dev/null +++ b/app/src/main/assets/config/specialbuttons.json @@ -0,0 +1,14 @@ +{ + "desc": "游戏快捷菜单中快捷指令,键值参考PC端:https://learn.microsoft.com/en-us/windows/win32/inputdev/virtual-key-codes", + "data":[ + { + "name": "自定义导入数据 (切换显示器)", + "desc": "data中的数值为上方链接获取的16进制code,例如ctrl为0xA2,alt为0xA4,shift为0xA0,f12为0x7B,win为0x5B", + "data":["0xA2","0xA4","0xA0","0x7B"] + }, + { + "name": "自定义导入数据 (ALT+F4)", + "data":["0xA4","0x73"] + } + ] +} \ No newline at end of file diff --git "a/app/src/main/assets/config/\346\234\210\345\205\211\351\230\277\350\245\277\350\245\277\346\233\264\346\226\260\350\257\264\346\230\216.txt" "b/app/src/main/assets/config/\346\234\210\345\205\211\351\230\277\350\245\277\350\245\277\346\233\264\346\226\260\350\257\264\346\230\216.txt" new file mode 100755 index 0000000000..58ed02fb8a --- /dev/null +++ "b/app/src/main/assets/config/\346\234\210\345\205\211\351\230\277\350\245\277\350\245\277\346\233\264\346\226\260\350\257\264\346\230\216.txt" @@ -0,0 +1,20 @@ + + +2024.6.22 +1、新增几种自定义特殊指令(HDR\关机\睡眠\重启\注销) +2、ds4触控板虚拟手柄按钮 +3、强制使用设备震动马达震动 +4、手柄振幅强度调节&持续震动 +5、鼠标模式新增左右键互换 + + +2024.5.11 +1、多点触控模式支持灵敏度调节; +2、自定义特殊指令支持自定义(json文件导入); +3、支持多种鼠标模式切换(多点触控、触控板、普通鼠标、禁用触控/鼠标); +4、开关类型的鼠标右键-特殊自定义按键里面(可能对按住右键转视野有点作用,本人不玩这类型游戏,没测试); +5、精简性能模式下,支持点击弹出游戏快捷菜单; +6、手柄调试页面(对测试手柄震动的小伙伴有帮助); +7、最大码率可以提高到300Mbps(性能足够的,可以试试。) + + diff --git a/app/src/main/ic_launcher-web.png b/app/src/main/ic_launcher-web.png old mode 100644 new mode 100755 diff --git a/app/src/main/java/com/limelight/AppView.java b/app/src/main/java/com/limelight/AppView.java old mode 100644 new mode 100755 index 1337627e2e..88795e2dcc --- a/app/src/main/java/com/limelight/AppView.java +++ b/app/src/main/java/com/limelight/AppView.java @@ -1,665 +1,665 @@ -package com.limelight; - -import java.io.IOException; -import java.io.StringReader; -import java.util.HashSet; -import java.util.List; - -import com.limelight.computers.ComputerManagerListener; -import com.limelight.computers.ComputerManagerService; -import com.limelight.grid.AppGridAdapter; -import com.limelight.nvstream.http.ComputerDetails; -import com.limelight.nvstream.http.NvApp; -import com.limelight.nvstream.http.NvHTTP; -import com.limelight.nvstream.http.PairingManager; -import com.limelight.preferences.PreferenceConfiguration; -import com.limelight.ui.AdapterFragment; -import com.limelight.ui.AdapterFragmentCallbacks; -import com.limelight.utils.CacheHelper; -import com.limelight.utils.Dialog; -import com.limelight.utils.ServerHelper; -import com.limelight.utils.ShortcutHelper; -import com.limelight.utils.SpinnerDialog; -import com.limelight.utils.UiHelper; - -import android.app.Activity; -import android.app.Service; -import android.content.ComponentName; -import android.content.Intent; -import android.content.ServiceConnection; -import android.content.SharedPreferences; -import android.content.res.Configuration; -import android.graphics.Bitmap; -import android.graphics.drawable.BitmapDrawable; -import android.os.Build; -import android.os.Bundle; -import android.os.IBinder; -import android.view.ContextMenu; -import android.view.Menu; -import android.view.MenuItem; -import android.view.View; -import android.view.ContextMenu.ContextMenuInfo; -import android.widget.AbsListView; -import android.widget.AdapterView; -import android.widget.AdapterView.OnItemClickListener; -import android.widget.ImageView; -import android.widget.TextView; -import android.widget.Toast; -import android.widget.AdapterView.AdapterContextMenuInfo; - -import org.xmlpull.v1.XmlPullParserException; - -public class AppView extends Activity implements AdapterFragmentCallbacks { - private AppGridAdapter appGridAdapter; - private String uuidString; - private ShortcutHelper shortcutHelper; - - private ComputerDetails computer; - private ComputerManagerService.ApplistPoller poller; - private SpinnerDialog blockingLoadSpinner; - private String lastRawApplist; - private int lastRunningAppId; - private boolean suspendGridUpdates; - private boolean inForeground; - private boolean showHiddenApps; - private HashSet hiddenAppIds = new HashSet<>(); - - private final static int START_OR_RESUME_ID = 1; - private final static int QUIT_ID = 2; - private final static int START_WITH_QUIT = 4; - private final static int VIEW_DETAILS_ID = 5; - private final static int CREATE_SHORTCUT_ID = 6; - private final static int HIDE_APP_ID = 7; - - public final static String HIDDEN_APPS_PREF_FILENAME = "HiddenApps"; - - public final static String NAME_EXTRA = "Name"; - public final static String UUID_EXTRA = "UUID"; - public final static String NEW_PAIR_EXTRA = "NewPair"; - public final static String SHOW_HIDDEN_APPS_EXTRA = "ShowHiddenApps"; - - private ComputerManagerService.ComputerManagerBinder managerBinder; - private final ServiceConnection serviceConnection = new ServiceConnection() { - public void onServiceConnected(ComponentName className, IBinder binder) { - final ComputerManagerService.ComputerManagerBinder localBinder = - ((ComputerManagerService.ComputerManagerBinder)binder); - - // Wait in a separate thread to avoid stalling the UI - new Thread() { - @Override - public void run() { - // Wait for the binder to be ready - localBinder.waitForReady(); - - // Get the computer object - computer = localBinder.getComputer(uuidString); - if (computer == null) { - finish(); - return; - } - - // Add a launcher shortcut for this PC (forced, since this is user interaction) - shortcutHelper.createAppViewShortcut(computer, true, getIntent().getBooleanExtra(NEW_PAIR_EXTRA, false)); - shortcutHelper.reportComputerShortcutUsed(computer); - - try { - appGridAdapter = new AppGridAdapter(AppView.this, - PreferenceConfiguration.readPreferences(AppView.this), - computer, localBinder.getUniqueId(), - showHiddenApps); - } catch (Exception e) { - e.printStackTrace(); - finish(); - return; - } - - appGridAdapter.updateHiddenApps(hiddenAppIds, true); - - // Now make the binder visible. We must do this after appGridAdapter - // is set to prevent us from reaching updateUiWithServerinfo() and - // touching the appGridAdapter prior to initialization. - managerBinder = localBinder; - - // Load the app grid with cached data (if possible). - // This must be done _before_ startComputerUpdates() - // so the initial serverinfo response can update the running - // icon. - populateAppGridWithCache(); - - // Start updates - startComputerUpdates(); - - runOnUiThread(new Runnable() { - @Override - public void run() { - if (isFinishing() || isChangingConfigurations()) { - return; - } - - // Despite my best efforts to catch all conditions that could - // cause the activity to be destroyed when we try to commit - // I haven't been able to, so we have this try-catch block. - try { - getFragmentManager().beginTransaction() - .replace(R.id.appFragmentContainer, new AdapterFragment()) - .commitAllowingStateLoss(); - } catch (IllegalStateException e) { - e.printStackTrace(); - } - } - }); - } - }.start(); - } - - public void onServiceDisconnected(ComponentName className) { - managerBinder = null; - } - }; - - @Override - public void onConfigurationChanged(Configuration newConfig) { - super.onConfigurationChanged(newConfig); - - // If appGridAdapter is initialized, let it know about the configuration change. - // If not, it will pick it up when it initializes. - if (appGridAdapter != null) { - // Update the app grid adapter to create grid items with the correct layout - appGridAdapter.updateLayoutWithPreferences(this, PreferenceConfiguration.readPreferences(this)); - - try { - // Reinflate the app grid itself to pick up the layout change - getFragmentManager().beginTransaction() - .replace(R.id.appFragmentContainer, new AdapterFragment()) - .commitAllowingStateLoss(); - } catch (IllegalStateException e) { - e.printStackTrace(); - } - } - } - - private void startComputerUpdates() { - // Don't start polling if we're not bound or in the foreground - if (managerBinder == null || !inForeground) { - return; - } - - managerBinder.startPolling(new ComputerManagerListener() { - @Override - public void notifyComputerUpdated(final ComputerDetails details) { - // Do nothing if updates are suspended - if (suspendGridUpdates) { - return; - } - - // Don't care about other computers - if (!details.uuid.equalsIgnoreCase(uuidString)) { - return; - } - - if (details.state == ComputerDetails.State.OFFLINE) { - // The PC is unreachable now - AppView.this.runOnUiThread(new Runnable() { - @Override - public void run() { - // Display a toast to the user and quit the activity - Toast.makeText(AppView.this, getResources().getText(R.string.lost_connection), Toast.LENGTH_SHORT).show(); - finish(); - } - }); - - return; - } - - // Close immediately if the PC is no longer paired - if (details.state == ComputerDetails.State.ONLINE && details.pairState != PairingManager.PairState.PAIRED) { - AppView.this.runOnUiThread(new Runnable() { - @Override - public void run() { - // Disable shortcuts referencing this PC for now - shortcutHelper.disableComputerShortcut(details, - getResources().getString(R.string.scut_not_paired)); - - // Display a toast to the user and quit the activity - Toast.makeText(AppView.this, getResources().getText(R.string.scut_not_paired), Toast.LENGTH_SHORT).show(); - finish(); - } - }); - - return; - } - - // App list is the same or empty - if (details.rawAppList == null || details.rawAppList.equals(lastRawApplist)) { - - // Let's check if the running app ID changed - if (details.runningGameId != lastRunningAppId) { - // Update the currently running game using the app ID - lastRunningAppId = details.runningGameId; - updateUiWithServerinfo(details); - } - - return; - } - - lastRunningAppId = details.runningGameId; - lastRawApplist = details.rawAppList; - - try { - updateUiWithAppList(NvHTTP.getAppListByReader(new StringReader(details.rawAppList))); - updateUiWithServerinfo(details); - - if (blockingLoadSpinner != null) { - blockingLoadSpinner.dismiss(); - blockingLoadSpinner = null; - } - } catch (XmlPullParserException | IOException e) { - e.printStackTrace(); - } - } - }); - - if (poller == null) { - poller = managerBinder.createAppListPoller(computer); - } - poller.start(); - } - - private void stopComputerUpdates() { - if (poller != null) { - poller.stop(); - } - - if (managerBinder != null) { - managerBinder.stopPolling(); - } - - if (appGridAdapter != null) { - appGridAdapter.cancelQueuedOperations(); - } - } - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - // Assume we're in the foreground when created to avoid a race - // between binding to CMS and onResume() - inForeground = true; - - shortcutHelper = new ShortcutHelper(this); - - UiHelper.setLocale(this); - - setContentView(R.layout.activity_app_view); - - // Allow floating expanded PiP overlays while browsing apps - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - setShouldDockBigOverlays(false); - } - - UiHelper.notifyNewRootView(this); - - showHiddenApps = getIntent().getBooleanExtra(SHOW_HIDDEN_APPS_EXTRA, false); - uuidString = getIntent().getStringExtra(UUID_EXTRA); - - SharedPreferences hiddenAppsPrefs = getSharedPreferences(HIDDEN_APPS_PREF_FILENAME, MODE_PRIVATE); - for (String hiddenAppIdStr : hiddenAppsPrefs.getStringSet(uuidString, new HashSet())) { - hiddenAppIds.add(Integer.parseInt(hiddenAppIdStr)); - } - - String computerName = getIntent().getStringExtra(NAME_EXTRA); - - TextView label = findViewById(R.id.appListText); - setTitle(computerName); - label.setText(computerName); - - // Bind to the computer manager service - bindService(new Intent(this, ComputerManagerService.class), serviceConnection, - Service.BIND_AUTO_CREATE); - } - - private void updateHiddenApps(boolean hideImmediately) { - HashSet hiddenAppIdStringSet = new HashSet<>(); - - for (Integer hiddenAppId : hiddenAppIds) { - hiddenAppIdStringSet.add(hiddenAppId.toString()); - } - - getSharedPreferences(HIDDEN_APPS_PREF_FILENAME, MODE_PRIVATE) - .edit() - .putStringSet(uuidString, hiddenAppIdStringSet) - .apply(); - - appGridAdapter.updateHiddenApps(hiddenAppIds, hideImmediately); - } - - private void populateAppGridWithCache() { - try { - // Try to load from cache - lastRawApplist = CacheHelper.readInputStreamToString(CacheHelper.openCacheFileForInput(getCacheDir(), "applist", uuidString)); - List applist = NvHTTP.getAppListByReader(new StringReader(lastRawApplist)); - updateUiWithAppList(applist); - LimeLog.info("Loaded applist from cache"); - } catch (IOException | XmlPullParserException e) { - if (lastRawApplist != null) { - LimeLog.warning("Saved applist corrupted: "+lastRawApplist); - e.printStackTrace(); - } - LimeLog.info("Loading applist from the network"); - // We'll need to load from the network - loadAppsBlocking(); - } - } - - private void loadAppsBlocking() { - blockingLoadSpinner = SpinnerDialog.displayDialog(this, getResources().getString(R.string.applist_refresh_title), - getResources().getString(R.string.applist_refresh_msg), true); - } - - @Override - protected void onDestroy() { - super.onDestroy(); - - SpinnerDialog.closeDialogs(this); - Dialog.closeDialogs(); - - if (managerBinder != null) { - unbindService(serviceConnection); - } - } - - @Override - protected void onResume() { - super.onResume(); - - // Display a decoder crash notification if we've returned after a crash - UiHelper.showDecoderCrashDialog(this); - - inForeground = true; - startComputerUpdates(); - } - - @Override - protected void onPause() { - super.onPause(); - - inForeground = false; - stopComputerUpdates(); - } - - @Override - public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) { - super.onCreateContextMenu(menu, v, menuInfo); - - AdapterContextMenuInfo info = (AdapterContextMenuInfo) menuInfo; - AppObject selectedApp = (AppObject) appGridAdapter.getItem(info.position); - - menu.setHeaderTitle(selectedApp.app.getAppName()); - - if (lastRunningAppId != 0) { - if (lastRunningAppId == selectedApp.app.getAppId()) { - menu.add(Menu.NONE, START_OR_RESUME_ID, 1, getResources().getString(R.string.applist_menu_resume)); - menu.add(Menu.NONE, QUIT_ID, 2, getResources().getString(R.string.applist_menu_quit)); - } - else { - menu.add(Menu.NONE, START_WITH_QUIT, 1, getResources().getString(R.string.applist_menu_quit_and_start)); - } - } - - // Only show the hide checkbox if this is not the currently running app or it's already hidden - if (lastRunningAppId != selectedApp.app.getAppId() || selectedApp.isHidden) { - MenuItem hideAppItem = menu.add(Menu.NONE, HIDE_APP_ID, 3, getResources().getString(R.string.applist_menu_hide_app)); - hideAppItem.setCheckable(true); - hideAppItem.setChecked(selectedApp.isHidden); - } - - menu.add(Menu.NONE, VIEW_DETAILS_ID, 4, getResources().getString(R.string.applist_menu_details)); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - // Only add an option to create shortcut if box art is loaded - // and when we're in grid-mode (not list-mode). - ImageView appImageView = info.targetView.findViewById(R.id.grid_image); - if (appImageView != null) { - // We have a grid ImageView, so we must be in grid-mode - BitmapDrawable drawable = (BitmapDrawable)appImageView.getDrawable(); - if (drawable != null && drawable.getBitmap() != null) { - // We have a bitmap loaded too - menu.add(Menu.NONE, CREATE_SHORTCUT_ID, 5, getResources().getString(R.string.applist_menu_scut)); - } - } - } - } - - @Override - public void onContextMenuClosed(Menu menu) { - } - - @Override - public boolean onContextItemSelected(MenuItem item) { - AdapterContextMenuInfo info = (AdapterContextMenuInfo) item.getMenuInfo(); - final AppObject app = (AppObject) appGridAdapter.getItem(info.position); - switch (item.getItemId()) { - case START_WITH_QUIT: - // Display a confirmation dialog first - UiHelper.displayQuitConfirmationDialog(this, new Runnable() { - @Override - public void run() { - ServerHelper.doStart(AppView.this, app.app, computer, managerBinder); - } - }, null); - return true; - - case START_OR_RESUME_ID: - // Resume is the same as start for us - ServerHelper.doStart(AppView.this, app.app, computer, managerBinder); - return true; - - case QUIT_ID: - // Display a confirmation dialog first - UiHelper.displayQuitConfirmationDialog(this, new Runnable() { - @Override - public void run() { - suspendGridUpdates = true; - ServerHelper.doQuit(AppView.this, computer, - app.app, managerBinder, new Runnable() { - @Override - public void run() { - // Trigger a poll immediately - suspendGridUpdates = false; - if (poller != null) { - poller.pollNow(); - } - } - }); - } - }, null); - return true; - - case VIEW_DETAILS_ID: - Dialog.displayDialog(AppView.this, getResources().getString(R.string.title_details), app.app.toString(), false); - return true; - - case HIDE_APP_ID: - if (item.isChecked()) { - // Transitioning hidden to shown - hiddenAppIds.remove(app.app.getAppId()); - } - else { - // Transitioning shown to hidden - hiddenAppIds.add(app.app.getAppId()); - } - updateHiddenApps(false); - return true; - - case CREATE_SHORTCUT_ID: - ImageView appImageView = info.targetView.findViewById(R.id.grid_image); - Bitmap appBits = ((BitmapDrawable)appImageView.getDrawable()).getBitmap(); - if (!shortcutHelper.createPinnedGameShortcut(computer, app.app, appBits)) { - Toast.makeText(AppView.this, getResources().getString(R.string.unable_to_pin_shortcut), Toast.LENGTH_LONG).show(); - } - return true; - - default: - return super.onContextItemSelected(item); - } - } - - private void updateUiWithServerinfo(final ComputerDetails details) { - AppView.this.runOnUiThread(new Runnable() { - @Override - public void run() { - boolean updated = false; - - // Look through our current app list to tag the running app - for (int i = 0; i < appGridAdapter.getCount(); i++) { - AppObject existingApp = (AppObject) appGridAdapter.getItem(i); - - // There can only be one or zero apps running. - if (existingApp.isRunning && - existingApp.app.getAppId() == details.runningGameId) { - // This app was running and still is, so we're done now - return; - } - else if (existingApp.app.getAppId() == details.runningGameId) { - // This app wasn't running but now is - existingApp.isRunning = true; - updated = true; - } - else if (existingApp.isRunning) { - // This app was running but now isn't - existingApp.isRunning = false; - updated = true; - } - else { - // This app wasn't running and still isn't - } - } - - if (updated) { - appGridAdapter.notifyDataSetChanged(); - } - } - }); - } - - private void updateUiWithAppList(final List appList) { - AppView.this.runOnUiThread(new Runnable() { - @Override - public void run() { - boolean updated = false; - - // First handle app updates and additions - for (NvApp app : appList) { - boolean foundExistingApp = false; - - // Try to update an existing app in the list first - for (int i = 0; i < appGridAdapter.getCount(); i++) { - AppObject existingApp = (AppObject) appGridAdapter.getItem(i); - if (existingApp.app.getAppId() == app.getAppId()) { - // Found the app; update its properties - if (!existingApp.app.getAppName().equals(app.getAppName())) { - existingApp.app.setAppName(app.getAppName()); - updated = true; - } - - foundExistingApp = true; - break; - } - } - - if (!foundExistingApp) { - // This app must be new - appGridAdapter.addApp(new AppObject(app)); - - // We could have a leftover shortcut from last time this PC was paired - // or if this app was removed then added again. Enable those shortcuts - // again if present. - shortcutHelper.enableAppShortcut(computer, app); - - updated = true; - } - } - - // Next handle app removals - int i = 0; - while (i < appGridAdapter.getCount()) { - boolean foundExistingApp = false; - AppObject existingApp = (AppObject) appGridAdapter.getItem(i); - - // Check if this app is in the latest list - for (NvApp app : appList) { - if (existingApp.app.getAppId() == app.getAppId()) { - foundExistingApp = true; - break; - } - } - - // This app was removed in the latest app list - if (!foundExistingApp) { - shortcutHelper.disableAppShortcut(computer, existingApp.app, "App removed from PC"); - appGridAdapter.removeApp(existingApp); - updated = true; - - // Check this same index again because the item at i+1 is now at i after - // the removal - continue; - } - - // Move on to the next item - i++; - } - - if (updated) { - appGridAdapter.notifyDataSetChanged(); - } - } - }); - } - - @Override - public int getAdapterFragmentLayoutId() { - return PreferenceConfiguration.readPreferences(AppView.this).smallIconMode ? - R.layout.app_grid_view_small : R.layout.app_grid_view; - } - - @Override - public void receiveAbsListView(AbsListView listView) { - listView.setAdapter(appGridAdapter); - listView.setOnItemClickListener(new OnItemClickListener() { - @Override - public void onItemClick(AdapterView arg0, View arg1, int pos, - long id) { - AppObject app = (AppObject) appGridAdapter.getItem(pos); - - // Only open the context menu if something is running, otherwise start it - if (lastRunningAppId != 0) { - openContextMenu(arg1); - } else { - ServerHelper.doStart(AppView.this, app.app, computer, managerBinder); - } - } - }); - UiHelper.applyStatusBarPadding(listView); - registerForContextMenu(listView); - listView.requestFocus(); - } - - public static class AppObject { - public final NvApp app; - public boolean isRunning; - public boolean isHidden; - - public AppObject(NvApp app) { - if (app == null) { - throw new IllegalArgumentException("app must not be null"); - } - this.app = app; - } - - @Override - public String toString() { - return app.getAppName(); - } - } -} +package com.limelight; + +import java.io.IOException; +import java.io.StringReader; +import java.util.HashSet; +import java.util.List; + +import com.limelight.computers.ComputerManagerListener; +import com.limelight.computers.ComputerManagerService; +import com.limelight.grid.AppGridAdapter; +import com.limelight.nvstream.http.ComputerDetails; +import com.limelight.nvstream.http.NvApp; +import com.limelight.nvstream.http.NvHTTP; +import com.limelight.nvstream.http.PairingManager; +import com.limelight.preferences.PreferenceConfiguration; +import com.limelight.ui.AdapterFragment; +import com.limelight.ui.AdapterFragmentCallbacks; +import com.limelight.utils.CacheHelper; +import com.limelight.utils.Dialog; +import com.limelight.utils.ServerHelper; +import com.limelight.utils.ShortcutHelper; +import com.limelight.utils.SpinnerDialog; +import com.limelight.utils.UiHelper; + +import android.app.Activity; +import android.app.Service; +import android.content.ComponentName; +import android.content.Intent; +import android.content.ServiceConnection; +import android.content.SharedPreferences; +import android.content.res.Configuration; +import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; +import android.os.Build; +import android.os.Bundle; +import android.os.IBinder; +import android.view.ContextMenu; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ContextMenu.ContextMenuInfo; +import android.widget.AbsListView; +import android.widget.AdapterView; +import android.widget.AdapterView.OnItemClickListener; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.Toast; +import android.widget.AdapterView.AdapterContextMenuInfo; + +import org.xmlpull.v1.XmlPullParserException; + +public class AppView extends Activity implements AdapterFragmentCallbacks { + private AppGridAdapter appGridAdapter; + private String uuidString; + private ShortcutHelper shortcutHelper; + + private ComputerDetails computer; + private ComputerManagerService.ApplistPoller poller; + private SpinnerDialog blockingLoadSpinner; + private String lastRawApplist; + private int lastRunningAppId; + private boolean suspendGridUpdates; + private boolean inForeground; + private boolean showHiddenApps; + private HashSet hiddenAppIds = new HashSet<>(); + + private final static int START_OR_RESUME_ID = 1; + private final static int QUIT_ID = 2; + private final static int START_WITH_QUIT = 4; + private final static int VIEW_DETAILS_ID = 5; + private final static int CREATE_SHORTCUT_ID = 6; + private final static int HIDE_APP_ID = 7; + + public final static String HIDDEN_APPS_PREF_FILENAME = "HiddenApps"; + + public final static String NAME_EXTRA = "Name"; + public final static String UUID_EXTRA = "UUID"; + public final static String NEW_PAIR_EXTRA = "NewPair"; + public final static String SHOW_HIDDEN_APPS_EXTRA = "ShowHiddenApps"; + + private ComputerManagerService.ComputerManagerBinder managerBinder; + private final ServiceConnection serviceConnection = new ServiceConnection() { + public void onServiceConnected(ComponentName className, IBinder binder) { + final ComputerManagerService.ComputerManagerBinder localBinder = + ((ComputerManagerService.ComputerManagerBinder)binder); + + // Wait in a separate thread to avoid stalling the UI + new Thread() { + @Override + public void run() { + // Wait for the binder to be ready + localBinder.waitForReady(); + + // Get the computer object + computer = localBinder.getComputer(uuidString); + if (computer == null) { + finish(); + return; + } + + // Add a launcher shortcut for this PC (forced, since this is user interaction) + shortcutHelper.createAppViewShortcut(computer, true, getIntent().getBooleanExtra(NEW_PAIR_EXTRA, false)); + shortcutHelper.reportComputerShortcutUsed(computer); + + try { + appGridAdapter = new AppGridAdapter(AppView.this, + PreferenceConfiguration.readPreferences(AppView.this), + computer, localBinder.getUniqueId(), + showHiddenApps); + } catch (Exception e) { + e.printStackTrace(); + finish(); + return; + } + + appGridAdapter.updateHiddenApps(hiddenAppIds, true); + + // Now make the binder visible. We must do this after appGridAdapter + // is set to prevent us from reaching updateUiWithServerinfo() and + // touching the appGridAdapter prior to initialization. + managerBinder = localBinder; + + // Load the app grid with cached data (if possible). + // This must be done _before_ startComputerUpdates() + // so the initial serverinfo response can update the running + // icon. + populateAppGridWithCache(); + + // Start updates + startComputerUpdates(); + + runOnUiThread(new Runnable() { + @Override + public void run() { + if (isFinishing() || isChangingConfigurations()) { + return; + } + + // Despite my best efforts to catch all conditions that could + // cause the activity to be destroyed when we try to commit + // I haven't been able to, so we have this try-catch block. + try { + getFragmentManager().beginTransaction() + .replace(R.id.appFragmentContainer, new AdapterFragment()) + .commitAllowingStateLoss(); + } catch (IllegalStateException e) { + e.printStackTrace(); + } + } + }); + } + }.start(); + } + + public void onServiceDisconnected(ComponentName className) { + managerBinder = null; + } + }; + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + + // If appGridAdapter is initialized, let it know about the configuration change. + // If not, it will pick it up when it initializes. + if (appGridAdapter != null) { + // Update the app grid adapter to create grid items with the correct layout + appGridAdapter.updateLayoutWithPreferences(this, PreferenceConfiguration.readPreferences(this)); + + try { + // Reinflate the app grid itself to pick up the layout change + getFragmentManager().beginTransaction() + .replace(R.id.appFragmentContainer, new AdapterFragment()) + .commitAllowingStateLoss(); + } catch (IllegalStateException e) { + e.printStackTrace(); + } + } + } + + private void startComputerUpdates() { + // Don't start polling if we're not bound or in the foreground + if (managerBinder == null || !inForeground) { + return; + } + + managerBinder.startPolling(new ComputerManagerListener() { + @Override + public void notifyComputerUpdated(final ComputerDetails details) { + // Do nothing if updates are suspended + if (suspendGridUpdates) { + return; + } + + // Don't care about other computers + if (!details.uuid.equalsIgnoreCase(uuidString)) { + return; + } + + if (details.state == ComputerDetails.State.OFFLINE) { + // The PC is unreachable now + AppView.this.runOnUiThread(new Runnable() { + @Override + public void run() { + // Display a toast to the user and quit the activity + Toast.makeText(AppView.this, getResources().getText(R.string.lost_connection), Toast.LENGTH_SHORT).show(); + finish(); + } + }); + + return; + } + + // Close immediately if the PC is no longer paired + if (details.state == ComputerDetails.State.ONLINE && details.pairState != PairingManager.PairState.PAIRED) { + AppView.this.runOnUiThread(new Runnable() { + @Override + public void run() { + // Disable shortcuts referencing this PC for now + shortcutHelper.disableComputerShortcut(details, + getResources().getString(R.string.scut_not_paired)); + + // Display a toast to the user and quit the activity + Toast.makeText(AppView.this, getResources().getText(R.string.scut_not_paired), Toast.LENGTH_SHORT).show(); + finish(); + } + }); + + return; + } + + // App list is the same or empty + if (details.rawAppList == null || details.rawAppList.equals(lastRawApplist)) { + + // Let's check if the running app ID changed + if (details.runningGameId != lastRunningAppId) { + // Update the currently running game using the app ID + lastRunningAppId = details.runningGameId; + updateUiWithServerinfo(details); + } + + return; + } + + lastRunningAppId = details.runningGameId; + lastRawApplist = details.rawAppList; + + try { + updateUiWithAppList(NvHTTP.getAppListByReader(new StringReader(details.rawAppList))); + updateUiWithServerinfo(details); + + if (blockingLoadSpinner != null) { + blockingLoadSpinner.dismiss(); + blockingLoadSpinner = null; + } + } catch (XmlPullParserException | IOException e) { + e.printStackTrace(); + } + } + }); + + if (poller == null) { + poller = managerBinder.createAppListPoller(computer); + } + poller.start(); + } + + private void stopComputerUpdates() { + if (poller != null) { + poller.stop(); + } + + if (managerBinder != null) { + managerBinder.stopPolling(); + } + + if (appGridAdapter != null) { + appGridAdapter.cancelQueuedOperations(); + } + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // Assume we're in the foreground when created to avoid a race + // between binding to CMS and onResume() + inForeground = true; + + shortcutHelper = new ShortcutHelper(this); + + UiHelper.setLocale(this); + + setContentView(R.layout.activity_app_view); + + // Allow floating expanded PiP overlays while browsing apps + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + setShouldDockBigOverlays(false); + } + + UiHelper.notifyNewRootView(this); + + showHiddenApps = getIntent().getBooleanExtra(SHOW_HIDDEN_APPS_EXTRA, false); + uuidString = getIntent().getStringExtra(UUID_EXTRA); + + SharedPreferences hiddenAppsPrefs = getSharedPreferences(HIDDEN_APPS_PREF_FILENAME, MODE_PRIVATE); + for (String hiddenAppIdStr : hiddenAppsPrefs.getStringSet(uuidString, new HashSet())) { + hiddenAppIds.add(Integer.parseInt(hiddenAppIdStr)); + } + + String computerName = getIntent().getStringExtra(NAME_EXTRA); + + TextView label = findViewById(R.id.appListText); + setTitle(computerName); + label.setText(computerName); + + // Bind to the computer manager service + bindService(new Intent(this, ComputerManagerService.class), serviceConnection, + Service.BIND_AUTO_CREATE); + } + + private void updateHiddenApps(boolean hideImmediately) { + HashSet hiddenAppIdStringSet = new HashSet<>(); + + for (Integer hiddenAppId : hiddenAppIds) { + hiddenAppIdStringSet.add(hiddenAppId.toString()); + } + + getSharedPreferences(HIDDEN_APPS_PREF_FILENAME, MODE_PRIVATE) + .edit() + .putStringSet(uuidString, hiddenAppIdStringSet) + .apply(); + + appGridAdapter.updateHiddenApps(hiddenAppIds, hideImmediately); + } + + private void populateAppGridWithCache() { + try { + // Try to load from cache + lastRawApplist = CacheHelper.readInputStreamToString(CacheHelper.openCacheFileForInput(getCacheDir(), "applist", uuidString)); + List applist = NvHTTP.getAppListByReader(new StringReader(lastRawApplist)); + updateUiWithAppList(applist); + LimeLog.info("Loaded applist from cache"); + } catch (IOException | XmlPullParserException e) { + if (lastRawApplist != null) { + LimeLog.warning("Saved applist corrupted: "+lastRawApplist); + e.printStackTrace(); + } + LimeLog.info("Loading applist from the network"); + // We'll need to load from the network + loadAppsBlocking(); + } + } + + private void loadAppsBlocking() { + blockingLoadSpinner = SpinnerDialog.displayDialog(this, getResources().getString(R.string.applist_refresh_title), + getResources().getString(R.string.applist_refresh_msg), true); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + + SpinnerDialog.closeDialogs(this); + Dialog.closeDialogs(); + + if (managerBinder != null) { + unbindService(serviceConnection); + } + } + + @Override + protected void onResume() { + super.onResume(); + + // Display a decoder crash notification if we've returned after a crash + UiHelper.showDecoderCrashDialog(this); + + inForeground = true; + startComputerUpdates(); + } + + @Override + protected void onPause() { + super.onPause(); + + inForeground = false; + stopComputerUpdates(); + } + + @Override + public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) { + super.onCreateContextMenu(menu, v, menuInfo); + + AdapterContextMenuInfo info = (AdapterContextMenuInfo) menuInfo; + AppObject selectedApp = (AppObject) appGridAdapter.getItem(info.position); + + menu.setHeaderTitle(selectedApp.app.getAppName()); + + if (lastRunningAppId != 0) { + if (lastRunningAppId == selectedApp.app.getAppId()) { + menu.add(Menu.NONE, START_OR_RESUME_ID, 1, getResources().getString(R.string.applist_menu_resume)); + menu.add(Menu.NONE, QUIT_ID, 2, getResources().getString(R.string.applist_menu_quit)); + } + else { + menu.add(Menu.NONE, START_WITH_QUIT, 1, getResources().getString(R.string.applist_menu_quit_and_start)); + } + } + + // Only show the hide checkbox if this is not the currently running app or it's already hidden + if (lastRunningAppId != selectedApp.app.getAppId() || selectedApp.isHidden) { + MenuItem hideAppItem = menu.add(Menu.NONE, HIDE_APP_ID, 3, getResources().getString(R.string.applist_menu_hide_app)); + hideAppItem.setCheckable(true); + hideAppItem.setChecked(selectedApp.isHidden); + } + + menu.add(Menu.NONE, VIEW_DETAILS_ID, 4, getResources().getString(R.string.applist_menu_details)); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // Only add an option to create shortcut if box art is loaded + // and when we're in grid-mode (not list-mode). + ImageView appImageView = info.targetView.findViewById(R.id.grid_image); + if (appImageView != null) { + // We have a grid ImageView, so we must be in grid-mode + BitmapDrawable drawable = (BitmapDrawable)appImageView.getDrawable(); + if (drawable != null && drawable.getBitmap() != null) { + // We have a bitmap loaded too + menu.add(Menu.NONE, CREATE_SHORTCUT_ID, 5, getResources().getString(R.string.applist_menu_scut)); + } + } + } + } + + @Override + public void onContextMenuClosed(Menu menu) { + } + + @Override + public boolean onContextItemSelected(MenuItem item) { + AdapterContextMenuInfo info = (AdapterContextMenuInfo) item.getMenuInfo(); + final AppObject app = (AppObject) appGridAdapter.getItem(info.position); + switch (item.getItemId()) { + case START_WITH_QUIT: + // Display a confirmation dialog first + UiHelper.displayQuitConfirmationDialog(this, new Runnable() { + @Override + public void run() { + ServerHelper.doStart(AppView.this, app.app, computer, managerBinder); + } + }, null); + return true; + + case START_OR_RESUME_ID: + // Resume is the same as start for us + ServerHelper.doStart(AppView.this, app.app, computer, managerBinder); + return true; + + case QUIT_ID: + // Display a confirmation dialog first + UiHelper.displayQuitConfirmationDialog(this, new Runnable() { + @Override + public void run() { + suspendGridUpdates = true; + ServerHelper.doQuit(AppView.this, computer, + app.app, managerBinder, new Runnable() { + @Override + public void run() { + // Trigger a poll immediately + suspendGridUpdates = false; + if (poller != null) { + poller.pollNow(); + } + } + }); + } + }, null); + return true; + + case VIEW_DETAILS_ID: + Dialog.displayDialog(AppView.this, getResources().getString(R.string.title_details), app.app.toString(), false); + return true; + + case HIDE_APP_ID: + if (item.isChecked()) { + // Transitioning hidden to shown + hiddenAppIds.remove(app.app.getAppId()); + } + else { + // Transitioning shown to hidden + hiddenAppIds.add(app.app.getAppId()); + } + updateHiddenApps(false); + return true; + + case CREATE_SHORTCUT_ID: + ImageView appImageView = info.targetView.findViewById(R.id.grid_image); + Bitmap appBits = ((BitmapDrawable)appImageView.getDrawable()).getBitmap(); + if (!shortcutHelper.createPinnedGameShortcut(computer, app.app, appBits)) { + Toast.makeText(AppView.this, getResources().getString(R.string.unable_to_pin_shortcut), Toast.LENGTH_LONG).show(); + } + return true; + + default: + return super.onContextItemSelected(item); + } + } + + private void updateUiWithServerinfo(final ComputerDetails details) { + AppView.this.runOnUiThread(new Runnable() { + @Override + public void run() { + boolean updated = false; + + // Look through our current app list to tag the running app + for (int i = 0; i < appGridAdapter.getCount(); i++) { + AppObject existingApp = (AppObject) appGridAdapter.getItem(i); + + // There can only be one or zero apps running. + if (existingApp.isRunning && + existingApp.app.getAppId() == details.runningGameId) { + // This app was running and still is, so we're done now + return; + } + else if (existingApp.app.getAppId() == details.runningGameId) { + // This app wasn't running but now is + existingApp.isRunning = true; + updated = true; + } + else if (existingApp.isRunning) { + // This app was running but now isn't + existingApp.isRunning = false; + updated = true; + } + else { + // This app wasn't running and still isn't + } + } + + if (updated) { + appGridAdapter.notifyDataSetChanged(); + } + } + }); + } + + private void updateUiWithAppList(final List appList) { + AppView.this.runOnUiThread(new Runnable() { + @Override + public void run() { + boolean updated = false; + + // First handle app updates and additions + for (NvApp app : appList) { + boolean foundExistingApp = false; + + // Try to update an existing app in the list first + for (int i = 0; i < appGridAdapter.getCount(); i++) { + AppObject existingApp = (AppObject) appGridAdapter.getItem(i); + if (existingApp.app.getAppId() == app.getAppId()) { + // Found the app; update its properties + if (!existingApp.app.getAppName().equals(app.getAppName())) { + existingApp.app.setAppName(app.getAppName()); + updated = true; + } + + foundExistingApp = true; + break; + } + } + + if (!foundExistingApp) { + // This app must be new + appGridAdapter.addApp(new AppObject(app)); + + // We could have a leftover shortcut from last time this PC was paired + // or if this app was removed then added again. Enable those shortcuts + // again if present. + shortcutHelper.enableAppShortcut(computer, app); + + updated = true; + } + } + + // Next handle app removals + int i = 0; + while (i < appGridAdapter.getCount()) { + boolean foundExistingApp = false; + AppObject existingApp = (AppObject) appGridAdapter.getItem(i); + + // Check if this app is in the latest list + for (NvApp app : appList) { + if (existingApp.app.getAppId() == app.getAppId()) { + foundExistingApp = true; + break; + } + } + + // This app was removed in the latest app list + if (!foundExistingApp) { + shortcutHelper.disableAppShortcut(computer, existingApp.app, "App removed from PC"); + appGridAdapter.removeApp(existingApp); + updated = true; + + // Check this same index again because the item at i+1 is now at i after + // the removal + continue; + } + + // Move on to the next item + i++; + } + + if (updated) { + appGridAdapter.notifyDataSetChanged(); + } + } + }); + } + + @Override + public int getAdapterFragmentLayoutId() { + return PreferenceConfiguration.readPreferences(AppView.this).smallIconMode ? + R.layout.app_grid_view_small : R.layout.app_grid_view; + } + + @Override + public void receiveAbsListView(AbsListView listView) { + listView.setAdapter(appGridAdapter); + listView.setOnItemClickListener(new OnItemClickListener() { + @Override + public void onItemClick(AdapterView arg0, View arg1, int pos, + long id) { + AppObject app = (AppObject) appGridAdapter.getItem(pos); + + // Only open the context menu if something is running, otherwise start it + if (lastRunningAppId != 0) { + openContextMenu(arg1); + } else { + ServerHelper.doStart(AppView.this, app.app, computer, managerBinder); + } + } + }); + UiHelper.applyStatusBarPadding(listView); + registerForContextMenu(listView); + listView.requestFocus(); + } + + public static class AppObject { + public final NvApp app; + public boolean isRunning; + public boolean isHidden; + + public AppObject(NvApp app) { + if (app == null) { + throw new IllegalArgumentException("app must not be null"); + } + this.app = app; + } + + @Override + public String toString() { + return app.getAppName(); + } + } +} diff --git a/app/src/main/java/com/limelight/AxiTestActivity.java b/app/src/main/java/com/limelight/AxiTestActivity.java new file mode 100755 index 0000000000..8ff38eddec --- /dev/null +++ b/app/src/main/java/com/limelight/AxiTestActivity.java @@ -0,0 +1,306 @@ +package com.limelight; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.ProgressDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.hardware.Sensor; +import android.media.AudioAttributes; +import android.os.Build; +import android.os.Bundle; +import android.os.VibrationAttributes; +import android.os.VibrationEffect; +import android.os.Vibrator; +import android.support.annotation.Nullable; +import android.text.InputFilter; +import android.text.InputType; +import android.text.TextUtils; +import android.view.InputDevice; +import android.view.MotionEvent; +import android.view.View; +import android.view.WindowManager; +import android.widget.Button; +import android.widget.EditText; +import android.widget.ProgressBar; +import android.widget.SeekBar; +import android.widget.TextView; +import android.widget.Toast; + +import com.limelight.utils.DeviceUtils; + +import org.w3c.dom.Text; + +import java.util.ArrayList; +import java.util.List; + +/** + * Description + * Date: 2024-05-06 + * Time: 16:11 + */ +public class AxiTestActivity extends Activity implements View.OnClickListener { + + private TextView tx_gamepad_info; + + private Vibrator vibrator; + + private Button bt_vibrator; + private List ids = new ArrayList<>(); + + private Vibrator vibratorOnline; + + private Button bt_vibrator_value; + + private int simulatedAmplitude=220; + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_axitest); + tx_gamepad_info = findViewById(R.id.tx_game_pad_info); + TextView tx_content=findViewById(R.id.tx_content); + bt_vibrator=findViewById(R.id.bt_vibrator); + + bt_vibrator_value=findViewById(R.id.bt_vibrator_value); + + vibrator = (Vibrator) this.getSystemService(this.VIBRATOR_SERVICE); + + getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + + String kernelVersion =System.getProperty("os.version"); + StringBuffer sb=new StringBuffer(); + sb.append("安卓版本:"+ DeviceUtils.getSDKVersionName()); + sb.append("\tapi版本:"+Build.VERSION.SDK_INT); + sb.append("\n内核版本:"+kernelVersion); + sb.append("\n品牌型号:"+DeviceUtils.getManufacturer()+"\t-\t"+DeviceUtils.getModel()); + tx_content.setText(sb.toString()); + + boolean hasVibrator=((Vibrator)getSystemService(Context.VIBRATOR_SERVICE)).hasVibrator(); + String content=hasVibrator?"有震动马达":"无震动马达"; + bt_vibrator.setText("测试设备震动("+content+")"); + + showSimlateAmp(); + } + + private void showSimlateAmp(){ + bt_vibrator_value.setText("振幅强度("+simulatedAmplitude+")"); + } + + + private void cancleRumble(){ + if(vibratorOnline!=null){ + vibratorOnline.cancel(); + } + if(vibrator!=null){ + vibrator.cancel(); + } + } + + @Override + public void onClick(View v) { + + if(v.getId()==R.id.bt_vibrator_cancle){ + cancleRumble(); + return; + } + //机身震动 + if (v.getId() == R.id.bt_vibrator) { + String[] titles=new String[]{"简单震一秒","持续HD震动"}; + new AlertDialog.Builder(this).setItems(titles, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.dismiss(); + switch (which){ + case 0: + vibrator.vibrate(1000); + break; + case 1: + rumble(vibrator); + break; + } + } + }).setTitle("请选择").create().show(); + return; + } + + //手柄震动 + if (v.getId() == R.id.bt_vibrator_gamepad) { + if(ids.size()==0){ + Toast.makeText(AxiTestActivity.this, "目前没有检测到手柄,请连接手柄,点击刷新按钮,再尝试!", Toast.LENGTH_LONG).show(); + return; + } + String[] strings = new String[ids.size()]; + for (int i = 0; i < ids.size(); i++) { + strings[i] = ids.get(i).getName(); + } + new AlertDialog.Builder(this).setItems(strings, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.dismiss(); + if (ids.get(which).getVibrator().hasVibrator()) { + String[] titles=new String[]{"简单震一秒","持续HD震动"}; + new AlertDialog.Builder(AxiTestActivity.this).setItems(titles, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which2) { + dialog.dismiss(); + switch (which2){ + case 0: + ids.get(which).getVibrator().vibrate(1000); + break; + case 1: + cancleRumble(); + vibratorOnline=ids.get(which).getVibrator(); + rumble(vibratorOnline); + break; + } + } + }).setTitle("请选择").create().show(); + } else { + Toast.makeText(AxiTestActivity.this, "手柄没有识别到震动传感器!", Toast.LENGTH_SHORT).show(); + } + + } + }).setTitle("请选择").create().show(); + + return; + } + //刷新手柄信息 + if (v.getId() == R.id.bt_update_gamepad) { + updateGamePad(); + return; + } + + if(v.getId()==R.id.bt_vibrator_value){ + SeekBar mSeekBar=new SeekBar(this); + mSeekBar.setMax(255); + mSeekBar.setProgress(simulatedAmplitude); + mSeekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + simulatedAmplitude=progress; + showSimlateAmp(); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + + } + }); + AlertDialog.Builder editDialog = new AlertDialog.Builder(this); + editDialog.setTitle("设置振幅0-255【HD震动生效】"); + //设置dialog布局 + editDialog.setView(mSeekBar); + editDialog.create().show(); + return; + } + +// if(v.getId()==R.id.bt_vibrator_setting){ +// Intent intent=new Intent(); +// intent.setClassName("com.android.settings","com.android.settings.SubSettings"); +// intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); +// startActivity(intent); +// return; +// } + } + + + private void rumble(Vibrator vibrator){ + long pwmPeriod = 20; + long onTime = (long)((simulatedAmplitude / 255.0) * pwmPeriod); + long offTime = pwmPeriod - onTime; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + VibrationAttributes vibrationAttributes = new VibrationAttributes.Builder() + .setUsage(VibrationAttributes.USAGE_HARDWARE_FEEDBACK) + .build(); + vibrator.vibrate(VibrationEffect.createWaveform(new long[]{0, onTime, offTime}, 0), vibrationAttributes); + } + else { + AudioAttributes audioAttributes = new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_GAME) + .build(); + vibrator.vibrate(new long[]{0, onTime, offTime}, 0, audioAttributes); + } + } + + + @Override + protected void onDestroy() { + super.onDestroy(); + if(vibratorOnline!=null){ + vibratorOnline.cancel(); + } + } + + private void updateGamePad() { + ids.clear(); + StringBuffer sb = new StringBuffer(); + sb.append("\n"); + int[] deviceIds = InputDevice.getDeviceIds(); + for (int deviceId : deviceIds) { + InputDevice dev = InputDevice.getDevice(deviceId); + int sources = dev.getSources(); + if (((sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD) + || ((sources & InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK)) { + if (getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_X) != null && + getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_Y) != null) { + // This is a gamepad + ids.add(dev); + //android 12 + sb.append("名称:"+dev.getName()); + sb.append("\n"); + sb.append("传感器:"); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + String sensor=""; + if (dev.getSensorManager().getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null) { + sensor="+加速度传感器"; + } + if(dev.getSensorManager().getDefaultSensor(Sensor.TYPE_GYROSCOPE) != null){ + sensor="+陀螺仪"; + } + if(sensor.length()==0){ + sb.append("无(没有相关驱动或者手柄不支持)"); + }else{ + sb.append(sensor); + } + sb.append("\n"); + }else{ + sb.append("低于android12没有对应API"); + sb.append("\n"); + } + sb.append("VID_PID:"+dev.getVendorId()+"_"+dev.getProductId() + +"\t ["+String.format("%04x", dev.getVendorId())+"_"+String.format("%04x", dev.getProductId())+"]"); + sb.append("\n"); + sb.append("震动:"+(dev.getVibrator().hasVibrator()?"支持":"不支持")); + sb.append("\n"); + sb.append("详细信息:\n"); + sb.append(dev.toString()); + sb.append("\n"); + } + + } + } + + tx_gamepad_info.setText("手柄数量:" + ids.size() + "\n" + sb.toString()); + } + + + private static InputDevice.MotionRange getMotionRangeForJoystickAxis(InputDevice dev, int axis) { + InputDevice.MotionRange range; + + // First get the axis for SOURCE_JOYSTICK + range = dev.getMotionRange(axis, InputDevice.SOURCE_JOYSTICK); + if (range == null) { + // Now try the axis for SOURCE_GAMEPAD + range = dev.getMotionRange(axis, InputDevice.SOURCE_GAMEPAD); + } + + return range; + } + +} diff --git a/app/src/main/java/com/limelight/Game.java b/app/src/main/java/com/limelight/Game.java old mode 100644 new mode 100755 index 5d214508a3..22e6df89f9 --- a/app/src/main/java/com/limelight/Game.java +++ b/app/src/main/java/com/limelight/Game.java @@ -1,2676 +1,3066 @@ -package com.limelight; - - -import com.limelight.binding.PlatformBinding; -import com.limelight.binding.audio.AndroidAudioRenderer; -import com.limelight.binding.input.ControllerHandler; -import com.limelight.binding.input.KeyboardTranslator; -import com.limelight.binding.input.capture.InputCaptureManager; -import com.limelight.binding.input.capture.InputCaptureProvider; -import com.limelight.binding.input.touch.AbsoluteTouchContext; -import com.limelight.binding.input.touch.RelativeTouchContext; -import com.limelight.binding.input.driver.UsbDriverService; -import com.limelight.binding.input.evdev.EvdevListener; -import com.limelight.binding.input.touch.TouchContext; -import com.limelight.binding.input.virtual_controller.VirtualController; -import com.limelight.binding.video.CrashListener; -import com.limelight.binding.video.MediaCodecDecoderRenderer; -import com.limelight.binding.video.MediaCodecHelper; -import com.limelight.binding.video.PerfOverlayListener; -import com.limelight.nvstream.NvConnection; -import com.limelight.nvstream.NvConnectionListener; -import com.limelight.nvstream.StreamConfiguration; -import com.limelight.nvstream.http.ComputerDetails; -import com.limelight.nvstream.http.NvApp; -import com.limelight.nvstream.http.NvHTTP; -import com.limelight.nvstream.input.ControllerPacket; -import com.limelight.nvstream.input.KeyboardPacket; -import com.limelight.nvstream.input.MouseButtonPacket; -import com.limelight.nvstream.jni.MoonBridge; -import com.limelight.preferences.GlPreferences; -import com.limelight.preferences.PreferenceConfiguration; -import com.limelight.ui.GameGestures; -import com.limelight.ui.StreamView; -import com.limelight.utils.Dialog; -import com.limelight.utils.ServerHelper; -import com.limelight.utils.ShortcutHelper; -import com.limelight.utils.SpinnerDialog; -import com.limelight.utils.UiHelper; - -import android.annotation.SuppressLint; -import android.annotation.TargetApi; -import android.app.Activity; -import android.app.PictureInPictureParams; -import android.app.Service; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.ServiceConnection; -import android.content.SharedPreferences; -import android.content.pm.ActivityInfo; -import android.content.pm.PackageManager; -import android.content.res.Configuration; -import android.graphics.Point; -import android.graphics.Rect; -import android.hardware.input.InputManager; -import android.media.AudioManager; -import android.net.ConnectivityManager; -import android.net.wifi.WifiManager; -import android.os.Build; -import android.os.Bundle; -import android.os.Handler; -import android.os.IBinder; -import android.util.Rational; -import android.view.Display; -import android.view.InputDevice; -import android.view.KeyCharacterMap; -import android.view.KeyEvent; -import android.view.MotionEvent; -import android.view.Surface; -import android.view.SurfaceHolder; -import android.view.View; -import android.view.View.OnGenericMotionListener; -import android.view.View.OnSystemUiVisibilityChangeListener; -import android.view.View.OnTouchListener; -import android.view.Window; -import android.view.WindowManager; -import android.widget.FrameLayout; -import android.view.inputmethod.InputMethodManager; -import android.widget.TextView; -import android.widget.Toast; - -import java.io.ByteArrayInputStream; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.security.cert.CertificateException; -import java.security.cert.CertificateFactory; -import java.security.cert.X509Certificate; -import java.util.Locale; - - -public class Game extends Activity implements SurfaceHolder.Callback, - OnGenericMotionListener, OnTouchListener, NvConnectionListener, EvdevListener, - OnSystemUiVisibilityChangeListener, GameGestures, StreamView.InputCallbacks, - PerfOverlayListener, UsbDriverService.UsbDriverStateListener, View.OnKeyListener { - private int lastButtonState = 0; - - // Only 2 touches are supported - private final TouchContext[] touchContextMap = new TouchContext[2]; - private long threeFingerDownTime = 0; - - private static final int REFERENCE_HORIZ_RES = 1280; - private static final int REFERENCE_VERT_RES = 720; - - private static final int STYLUS_DOWN_DEAD_ZONE_DELAY = 100; - private static final int STYLUS_DOWN_DEAD_ZONE_RADIUS = 20; - - private static final int STYLUS_UP_DEAD_ZONE_DELAY = 150; - private static final int STYLUS_UP_DEAD_ZONE_RADIUS = 50; - - private static final int THREE_FINGER_TAP_THRESHOLD = 300; - - private ControllerHandler controllerHandler; - private KeyboardTranslator keyboardTranslator; - private VirtualController virtualController; - - private PreferenceConfiguration prefConfig; - private SharedPreferences tombstonePrefs; - - private NvConnection conn; - private SpinnerDialog spinner; - private boolean displayedFailureDialog = false; - private boolean connecting = false; - private boolean connected = false; - private boolean autoEnterPip = false; - private boolean surfaceCreated = false; - private boolean attemptedConnection = false; - private int suppressPipRefCount = 0; - private String pcName; - private String appName; - private NvApp app; - private float desiredRefreshRate; - - private InputCaptureProvider inputCaptureProvider; - private int modifierFlags = 0; - private boolean grabbedInput = true; - private boolean cursorVisible = false; - private boolean waitingForAllModifiersUp = false; - private int specialKeyCode = KeyEvent.KEYCODE_UNKNOWN; - private StreamView streamView; - private long lastAbsTouchUpTime = 0; - private long lastAbsTouchDownTime = 0; - private float lastAbsTouchUpX, lastAbsTouchUpY; - private float lastAbsTouchDownX, lastAbsTouchDownY; - - private boolean isHidingOverlays; - private TextView notificationOverlayView; - private int requestedNotificationOverlayVisibility = View.GONE; - private TextView performanceOverlayView; - - private MediaCodecDecoderRenderer decoderRenderer; - private boolean reportedCrash; - - private WifiManager.WifiLock highPerfWifiLock; - private WifiManager.WifiLock lowLatencyWifiLock; - - private boolean connectedToUsbDriverService = false; - private ServiceConnection usbDriverServiceConnection = new ServiceConnection() { - @Override - public void onServiceConnected(ComponentName componentName, IBinder iBinder) { - UsbDriverService.UsbDriverBinder binder = (UsbDriverService.UsbDriverBinder) iBinder; - binder.setListener(controllerHandler); - binder.setStateListener(Game.this); - binder.start(); - connectedToUsbDriverService = true; - } - - @Override - public void onServiceDisconnected(ComponentName componentName) { - connectedToUsbDriverService = false; - } - }; - - public static final String EXTRA_HOST = "Host"; - public static final String EXTRA_PORT = "Port"; - public static final String EXTRA_HTTPS_PORT = "HttpsPort"; - public static final String EXTRA_APP_NAME = "AppName"; - public static final String EXTRA_APP_ID = "AppId"; - public static final String EXTRA_UNIQUEID = "UniqueId"; - public static final String EXTRA_PC_UUID = "UUID"; - public static final String EXTRA_PC_NAME = "PcName"; - public static final String EXTRA_APP_HDR = "HDR"; - public static final String EXTRA_SERVER_CERT = "ServerCert"; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - UiHelper.setLocale(this); - - // We don't want a title bar - requestWindowFeature(Window.FEATURE_NO_TITLE); - - // Full-screen - getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); - - // If we're going to use immersive mode, we want to have - // the entire screen - getWindow().getDecorView().setSystemUiVisibility( - View.SYSTEM_UI_FLAG_LAYOUT_STABLE | - View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | - View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN); - - getWindow().addFlags(WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN); - - // Listen for UI visibility events - getWindow().getDecorView().setOnSystemUiVisibilityChangeListener(this); - - // Change volume button behavior - setVolumeControlStream(AudioManager.STREAM_MUSIC); - - // Inflate the content - setContentView(R.layout.activity_game); - - // Start the spinner - spinner = SpinnerDialog.displayDialog(this, getResources().getString(R.string.conn_establishing_title), - getResources().getString(R.string.conn_establishing_msg), true); - - // Read the stream preferences - prefConfig = PreferenceConfiguration.readPreferences(this); - tombstonePrefs = Game.this.getSharedPreferences("DecoderTombstone", 0); - - // Enter landscape unless we're on a square screen - setPreferredOrientationForCurrentDisplay(); - - if (prefConfig.stretchVideo || shouldIgnoreInsetsForResolution(prefConfig.width, prefConfig.height)) { - // Allow the activity to layout under notches if the fill-screen option - // was turned on by the user or it's a full-screen native resolution - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - getWindow().getAttributes().layoutInDisplayCutoutMode = - WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS; - } - else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - getWindow().getAttributes().layoutInDisplayCutoutMode = - WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; - } - } - - // Listen for non-touch events on the game surface - streamView = findViewById(R.id.surfaceView); - streamView.setOnGenericMotionListener(this); - streamView.setOnKeyListener(this); - streamView.setInputCallbacks(this); - - // Listen for touch events on the background touch view to enable trackpad mode - // to work on areas outside of the StreamView itself. We use a separate View - // for this rather than just handling it at the Activity level, because that - // allows proper touch splitting, which the OSC relies upon. - View backgroundTouchView = findViewById(R.id.backgroundTouchView); - backgroundTouchView.setOnTouchListener(this); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - // Request unbuffered input event dispatching for all input classes we handle here. - // Without this, input events are buffered to be delivered in lock-step with VBlank, - // artificially increasing input latency while streaming. - streamView.requestUnbufferedDispatch( - InputDevice.SOURCE_CLASS_BUTTON | // Keyboards - InputDevice.SOURCE_CLASS_JOYSTICK | // Gamepads - InputDevice.SOURCE_CLASS_POINTER | // Touchscreens and mice (w/o pointer capture) - InputDevice.SOURCE_CLASS_POSITION | // Touchpads - InputDevice.SOURCE_CLASS_TRACKBALL // Mice (pointer capture) - ); - backgroundTouchView.requestUnbufferedDispatch( - InputDevice.SOURCE_CLASS_BUTTON | // Keyboards - InputDevice.SOURCE_CLASS_JOYSTICK | // Gamepads - InputDevice.SOURCE_CLASS_POINTER | // Touchscreens and mice (w/o pointer capture) - InputDevice.SOURCE_CLASS_POSITION | // Touchpads - InputDevice.SOURCE_CLASS_TRACKBALL // Mice (pointer capture) - ); - } - - notificationOverlayView = findViewById(R.id.notificationOverlay); - - performanceOverlayView = findViewById(R.id.performanceOverlay); - - inputCaptureProvider = InputCaptureManager.getInputCaptureProvider(this, this); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - streamView.setOnCapturedPointerListener(new View.OnCapturedPointerListener() { - @Override - public boolean onCapturedPointer(View view, MotionEvent motionEvent) { - return handleMotionEvent(view, motionEvent); - } - }); - } - - // Warn the user if they're on a metered connection - ConnectivityManager connMgr = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE); - if (connMgr.isActiveNetworkMetered()) { - displayTransientMessage(getResources().getString(R.string.conn_metered)); - } - - // Make sure Wi-Fi is fully powered up - WifiManager wifiMgr = (WifiManager) getApplicationContext().getSystemService(Context.WIFI_SERVICE); - try { - highPerfWifiLock = wifiMgr.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "Moonlight High Perf Lock"); - highPerfWifiLock.setReferenceCounted(false); - highPerfWifiLock.acquire(); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - lowLatencyWifiLock = wifiMgr.createWifiLock(WifiManager.WIFI_MODE_FULL_LOW_LATENCY, "Moonlight Low Latency Lock"); - lowLatencyWifiLock.setReferenceCounted(false); - lowLatencyWifiLock.acquire(); - } - } catch (SecurityException e) { - // Some Samsung Galaxy S10+/S10e devices throw a SecurityException from - // WifiLock.acquire() even though we have android.permission.WAKE_LOCK in our manifest. - e.printStackTrace(); - } - - appName = Game.this.getIntent().getStringExtra(EXTRA_APP_NAME); - pcName = Game.this.getIntent().getStringExtra(EXTRA_PC_NAME); - - String host = Game.this.getIntent().getStringExtra(EXTRA_HOST); - int port = Game.this.getIntent().getIntExtra(EXTRA_PORT, NvHTTP.DEFAULT_HTTP_PORT); - int httpsPort = Game.this.getIntent().getIntExtra(EXTRA_HTTPS_PORT, 0); // 0 is treated as unknown - int appId = Game.this.getIntent().getIntExtra(EXTRA_APP_ID, StreamConfiguration.INVALID_APP_ID); - String uniqueId = Game.this.getIntent().getStringExtra(EXTRA_UNIQUEID); - boolean appSupportsHdr = Game.this.getIntent().getBooleanExtra(EXTRA_APP_HDR, false); - byte[] derCertData = Game.this.getIntent().getByteArrayExtra(EXTRA_SERVER_CERT); - - app = new NvApp(appName != null ? appName : "app", appId, appSupportsHdr); - - X509Certificate serverCert = null; - try { - if (derCertData != null) { - serverCert = (X509Certificate) CertificateFactory.getInstance("X.509") - .generateCertificate(new ByteArrayInputStream(derCertData)); - } - } catch (CertificateException e) { - e.printStackTrace(); - } - - if (appId == StreamConfiguration.INVALID_APP_ID) { - finish(); - return; - } - - // Initialize the MediaCodec helper before creating the decoder - GlPreferences glPrefs = GlPreferences.readPreferences(this); - MediaCodecHelper.initialize(this, glPrefs.glRenderer); - - // Check if the user has enabled HDR - boolean willStreamHdr = false; - if (prefConfig.enableHdr) { - // Start our HDR checklist - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - Display display = getWindowManager().getDefaultDisplay(); - Display.HdrCapabilities hdrCaps = display.getHdrCapabilities(); - - // We must now ensure our display is compatible with HDR10 - if (hdrCaps != null) { - // getHdrCapabilities() returns null on Lenovo Lenovo Mirage Solo (vega), Android 8.0 - for (int hdrType : hdrCaps.getSupportedHdrTypes()) { - if (hdrType == Display.HdrCapabilities.HDR_TYPE_HDR10) { - willStreamHdr = true; - break; - } - } - } - - if (!willStreamHdr) { - // Nope, no HDR for us :( - Toast.makeText(this, "Display does not support HDR10", Toast.LENGTH_LONG).show(); - } - } - else { - Toast.makeText(this, "HDR requires Android 7.0 or later", Toast.LENGTH_LONG).show(); - } - } - - // Check if the user has enabled performance stats overlay - if (prefConfig.enablePerfOverlay) { - performanceOverlayView.setVisibility(View.VISIBLE); - } - - decoderRenderer = new MediaCodecDecoderRenderer( - this, - prefConfig, - new CrashListener() { - @Override - public void notifyCrash(Exception e) { - // The MediaCodec instance is going down due to a crash - // let's tell the user something when they open the app again - - // We must use commit because the app will crash when we return from this function - tombstonePrefs.edit().putInt("CrashCount", tombstonePrefs.getInt("CrashCount", 0) + 1).commit(); - reportedCrash = true; - } - }, - tombstonePrefs.getInt("CrashCount", 0), - connMgr.isActiveNetworkMetered(), - willStreamHdr, - glPrefs.glRenderer, - this); - - // Don't stream HDR if the decoder can't support it - if (willStreamHdr && !decoderRenderer.isHevcMain10Hdr10Supported() && !decoderRenderer.isAv1Main10Supported()) { - willStreamHdr = false; - Toast.makeText(this, "Decoder does not support HDR10 profile", Toast.LENGTH_LONG).show(); - } - - // Display a message to the user if HEVC was forced on but we still didn't find a decoder - if (prefConfig.videoFormat == PreferenceConfiguration.FormatOption.FORCE_HEVC && !decoderRenderer.isHevcSupported()) { - Toast.makeText(this, "No HEVC decoder found", Toast.LENGTH_LONG).show(); - } - - // Display a message to the user if AV1 was forced on but we still didn't find a decoder - if (prefConfig.videoFormat == PreferenceConfiguration.FormatOption.FORCE_AV1 && !decoderRenderer.isAv1Supported()) { - Toast.makeText(this, "No AV1 decoder found", Toast.LENGTH_LONG).show(); - } - - // H.264 is always supported - int supportedVideoFormats = MoonBridge.VIDEO_FORMAT_H264; - if (decoderRenderer.isHevcSupported()) { - supportedVideoFormats |= MoonBridge.VIDEO_FORMAT_H265; - if (willStreamHdr && decoderRenderer.isHevcMain10Hdr10Supported()) { - supportedVideoFormats |= MoonBridge.VIDEO_FORMAT_H265_MAIN10; - } - } - if (decoderRenderer.isAv1Supported()) { - supportedVideoFormats |= MoonBridge.VIDEO_FORMAT_AV1_MAIN8; - if (willStreamHdr && decoderRenderer.isAv1Main10Supported()) { - supportedVideoFormats |= MoonBridge.VIDEO_FORMAT_AV1_MAIN10; - } - } - - int gamepadMask = ControllerHandler.getAttachedControllerMask(this); - if (!prefConfig.multiController) { - // Always set gamepad 1 present for when multi-controller is - // disabled for games that don't properly support detection - // of gamepads removed and replugged at runtime. - gamepadMask = 1; - } - if (prefConfig.onscreenController) { - // If we're using OSC, always set at least gamepad 1. - gamepadMask |= 1; - } - - // Set to the optimal mode for streaming - float displayRefreshRate = prepareDisplayForRendering(); - LimeLog.info("Display refresh rate: "+displayRefreshRate); - - // If the user requested frame pacing using a capped FPS, we will need to change our - // desired FPS setting here in accordance with the active display refresh rate. - int roundedRefreshRate = Math.round(displayRefreshRate); - int chosenFrameRate = prefConfig.fps; - if (prefConfig.framePacing == PreferenceConfiguration.FRAME_PACING_CAP_FPS) { - if (prefConfig.fps >= roundedRefreshRate) { - if (prefConfig.fps > roundedRefreshRate + 3) { - // Use frame drops when rendering above the screen frame rate - prefConfig.framePacing = PreferenceConfiguration.FRAME_PACING_BALANCED; - LimeLog.info("Using drop mode for FPS > Hz"); - } else if (roundedRefreshRate <= 49) { - // Let's avoid clearly bogus refresh rates and fall back to legacy rendering - prefConfig.framePacing = PreferenceConfiguration.FRAME_PACING_BALANCED; - LimeLog.info("Bogus refresh rate: " + roundedRefreshRate); - } - else { - chosenFrameRate = roundedRefreshRate - 1; - LimeLog.info("Adjusting FPS target for screen to " + chosenFrameRate); - } - } - } - - StreamConfiguration config = new StreamConfiguration.Builder() - .setResolution(prefConfig.width, prefConfig.height) - .setLaunchRefreshRate(prefConfig.fps) - .setRefreshRate(chosenFrameRate) - .setApp(app) - .setBitrate(prefConfig.bitrate) - .setEnableSops(prefConfig.enableSops) - .enableLocalAudioPlayback(prefConfig.playHostAudio) - .setMaxPacketSize(1392) - .setRemoteConfiguration(StreamConfiguration.STREAM_CFG_AUTO) // NvConnection will perform LAN and VPN detection - .setSupportedVideoFormats(supportedVideoFormats) - .setAttachedGamepadMask(gamepadMask) - .setClientRefreshRateX100((int)(displayRefreshRate * 100)) - .setAudioConfiguration(prefConfig.audioConfiguration) - .setColorSpace(decoderRenderer.getPreferredColorSpace()) - .setColorRange(decoderRenderer.getPreferredColorRange()) - .setPersistGamepadsAfterDisconnect(!prefConfig.multiController) - .build(); - - // Initialize the connection - conn = new NvConnection(getApplicationContext(), - new ComputerDetails.AddressTuple(host, port), - httpsPort, uniqueId, config, - PlatformBinding.getCryptoProvider(this), serverCert); - controllerHandler = new ControllerHandler(this, conn, this, prefConfig); - keyboardTranslator = new KeyboardTranslator(); - - InputManager inputManager = (InputManager) getSystemService(Context.INPUT_SERVICE); - inputManager.registerInputDeviceListener(keyboardTranslator, null); - - // Initialize touch contexts - for (int i = 0; i < touchContextMap.length; i++) { - if (!prefConfig.touchscreenTrackpad) { - touchContextMap[i] = new AbsoluteTouchContext(conn, i, streamView); - } - else { - touchContextMap[i] = new RelativeTouchContext(conn, i, - REFERENCE_HORIZ_RES, REFERENCE_VERT_RES, - streamView, prefConfig); - } - } - - if (prefConfig.onscreenController) { - // create virtual onscreen controller - virtualController = new VirtualController(controllerHandler, - (FrameLayout)streamView.getParent(), - this); - virtualController.refreshLayout(); - virtualController.show(); - } - - if (prefConfig.usbDriver) { - // Start the USB driver - bindService(new Intent(this, UsbDriverService.class), - usbDriverServiceConnection, Service.BIND_AUTO_CREATE); - } - - if (!decoderRenderer.isAvcSupported()) { - if (spinner != null) { - spinner.dismiss(); - spinner = null; - } - - // If we can't find an AVC decoder, we can't proceed - Dialog.displayDialog(this, getResources().getString(R.string.conn_error_title), - "This device or ROM doesn't support hardware accelerated H.264 playback.", true); - return; - } - - // The connection will be started when the surface gets created - streamView.getHolder().addCallback(this); - } - - private void setPreferredOrientationForCurrentDisplay() { - Display display = getWindowManager().getDefaultDisplay(); - - // For semi-square displays, we use more complex logic to determine which orientation to use (if any) - if (PreferenceConfiguration.isSquarishScreen(display)) { - int desiredOrientation = Configuration.ORIENTATION_UNDEFINED; - - // OSC doesn't properly support portrait displays, so don't use it in portrait mode by default - if (prefConfig.onscreenController) { - desiredOrientation = Configuration.ORIENTATION_LANDSCAPE; - } - - // For native resolution, we will lock the orientation to the one that matches the specified resolution - if (PreferenceConfiguration.isNativeResolution(prefConfig.width, prefConfig.height)) { - if (prefConfig.width > prefConfig.height) { - desiredOrientation = Configuration.ORIENTATION_LANDSCAPE; - } - else { - desiredOrientation = Configuration.ORIENTATION_PORTRAIT; - } - } - - if (desiredOrientation == Configuration.ORIENTATION_LANDSCAPE) { - setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE); - } - else if (desiredOrientation == Configuration.ORIENTATION_PORTRAIT) { - setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT); - } - else { - // If we don't have a reason to lock to portrait or landscape, allow any orientation - setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_FULL_USER); - } - } - else { - // For regular displays, we always request landscape - setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE); - } - } - - @Override - public void onConfigurationChanged(Configuration newConfig) { - super.onConfigurationChanged(newConfig); - - // Set requested orientation for possible new screen size - setPreferredOrientationForCurrentDisplay(); - - if (virtualController != null) { - // Refresh layout of OSC for possible new screen size - virtualController.refreshLayout(); - } - - // Hide on-screen overlays in PiP mode - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - if (isInPictureInPictureMode()) { - isHidingOverlays = true; - - if (virtualController != null) { - virtualController.hide(); - } - - performanceOverlayView.setVisibility(View.GONE); - notificationOverlayView.setVisibility(View.GONE); - - // Disable sensors while in PiP mode - controllerHandler.disableSensors(); - - // Update GameManager state to indicate we're in PiP (still gaming, but interruptible) - UiHelper.notifyStreamEnteringPiP(this); - } - else { - isHidingOverlays = false; - - // Restore overlays to previous state when leaving PiP - - if (virtualController != null) { - virtualController.show(); - } - - if (prefConfig.enablePerfOverlay) { - performanceOverlayView.setVisibility(View.VISIBLE); - } - - notificationOverlayView.setVisibility(requestedNotificationOverlayVisibility); - - // Enable sensors again after exiting PiP - controllerHandler.enableSensors(); - - // Update GameManager state to indicate we're out of PiP (gaming, non-interruptible) - UiHelper.notifyStreamExitingPiP(this); - } - } - } - - @TargetApi(Build.VERSION_CODES.O) - private PictureInPictureParams getPictureInPictureParams(boolean autoEnter) { - PictureInPictureParams.Builder builder = - new PictureInPictureParams.Builder() - .setAspectRatio(new Rational(prefConfig.width, prefConfig.height)) - .setSourceRectHint(new Rect( - streamView.getLeft(), streamView.getTop(), - streamView.getRight(), streamView.getBottom())); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - builder.setAutoEnterEnabled(autoEnter); - builder.setSeamlessResizeEnabled(true); - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - if (appName != null) { - builder.setTitle(appName); - if (pcName != null) { - builder.setSubtitle(pcName); - } - } - else if (pcName != null) { - builder.setTitle(pcName); - } - } - - return builder.build(); - } - - private void updatePipAutoEnter() { - if (!prefConfig.enablePip) { - return; - } - - boolean autoEnter = connected && suppressPipRefCount == 0; - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - setPictureInPictureParams(getPictureInPictureParams(autoEnter)); - } - else { - autoEnterPip = autoEnter; - } - } - - public void setMetaKeyCaptureState(boolean enabled) { - // This uses custom APIs present on some Samsung devices to allow capture of - // meta key events while streaming. - try { - Class semWindowManager = Class.forName("com.samsung.android.view.SemWindowManager"); - Method getInstanceMethod = semWindowManager.getMethod("getInstance"); - Object manager = getInstanceMethod.invoke(null); - - if (manager != null) { - Class[] parameterTypes = new Class[2]; - parameterTypes[0] = ComponentName.class; - parameterTypes[1] = boolean.class; - Method requestMetaKeyEventMethod = semWindowManager.getDeclaredMethod("requestMetaKeyEvent", parameterTypes); - requestMetaKeyEventMethod.invoke(manager, this.getComponentName(), enabled); - } - else { - LimeLog.warning("SemWindowManager.getInstance() returned null"); - } - } catch (ClassNotFoundException e) { - e.printStackTrace(); - } catch (NoSuchMethodException e) { - e.printStackTrace(); - } catch (InvocationTargetException e) { - e.printStackTrace(); - } catch (IllegalAccessException e) { - e.printStackTrace(); - } - } - - @Override - public void onUserLeaveHint() { - super.onUserLeaveHint(); - - // PiP is only supported on Oreo and later, and we don't need to manually enter PiP on - // Android S and later. On Android R, we will use onPictureInPictureRequested() instead. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { - if (autoEnterPip) { - try { - // This has thrown all sorts of weird exceptions on Samsung devices - // running Oreo. Just eat them and close gracefully on leave, rather - // than crashing. - enterPictureInPictureMode(getPictureInPictureParams(false)); - } catch (Exception e) { - e.printStackTrace(); - } - } - } - } - - @Override - @TargetApi(Build.VERSION_CODES.R) - public boolean onPictureInPictureRequested() { - // Enter PiP when requested unless we're on Android 12 which supports auto-enter. - if (autoEnterPip && Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { - enterPictureInPictureMode(getPictureInPictureParams(false)); - } - return true; - } - - @Override - public void onWindowFocusChanged(boolean hasFocus) { - super.onWindowFocusChanged(hasFocus); - - // We can't guarantee the state of modifiers keys which may have - // lifted while focus was not on us. Clear the modifier state. - this.modifierFlags = 0; - - // With Android native pointer capture, capture is lost when focus is lost, - // so it must be requested again when focus is regained. - inputCaptureProvider.onWindowFocusChanged(hasFocus); - } - - private boolean isRefreshRateEqualMatch(float refreshRate) { - return refreshRate >= prefConfig.fps && - refreshRate <= prefConfig.fps + 3; - } - - private boolean isRefreshRateGoodMatch(float refreshRate) { - return refreshRate >= prefConfig.fps && - Math.round(refreshRate) % prefConfig.fps <= 3; - } - - private boolean shouldIgnoreInsetsForResolution(int width, int height) { - // Never ignore insets for non-native resolutions - if (!PreferenceConfiguration.isNativeResolution(width, height)) { - return false; - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - Display display = getWindowManager().getDefaultDisplay(); - for (Display.Mode candidate : display.getSupportedModes()) { - // Ignore insets if this is an exact match for the display resolution - if ((width == candidate.getPhysicalWidth() && height == candidate.getPhysicalHeight()) || - (height == candidate.getPhysicalWidth() && width == candidate.getPhysicalHeight())) { - return true; - } - } - } - - return false; - } - - private boolean mayReduceRefreshRate() { - return prefConfig.framePacing == PreferenceConfiguration.FRAME_PACING_CAP_FPS || - prefConfig.framePacing == PreferenceConfiguration.FRAME_PACING_MAX_SMOOTHNESS || - (prefConfig.framePacing == PreferenceConfiguration.FRAME_PACING_BALANCED && prefConfig.reduceRefreshRate); - } - - private float prepareDisplayForRendering() { - Display display = getWindowManager().getDefaultDisplay(); - WindowManager.LayoutParams windowLayoutParams = getWindow().getAttributes(); - float displayRefreshRate; - - // On M, we can explicitly set the optimal display mode - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - Display.Mode bestMode = display.getMode(); - boolean isNativeResolutionStream = PreferenceConfiguration.isNativeResolution(prefConfig.width, prefConfig.height); - boolean refreshRateIsGood = isRefreshRateGoodMatch(bestMode.getRefreshRate()); - boolean refreshRateIsEqual = isRefreshRateEqualMatch(bestMode.getRefreshRate()); - - LimeLog.info("Current display mode: "+bestMode.getPhysicalWidth()+"x"+ - bestMode.getPhysicalHeight()+"x"+bestMode.getRefreshRate()); - - for (Display.Mode candidate : display.getSupportedModes()) { - boolean refreshRateReduced = candidate.getRefreshRate() < bestMode.getRefreshRate(); - boolean resolutionReduced = candidate.getPhysicalWidth() < bestMode.getPhysicalWidth() || - candidate.getPhysicalHeight() < bestMode.getPhysicalHeight(); - boolean resolutionFitsStream = candidate.getPhysicalWidth() >= prefConfig.width && - candidate.getPhysicalHeight() >= prefConfig.height; - - LimeLog.info("Examining display mode: "+candidate.getPhysicalWidth()+"x"+ - candidate.getPhysicalHeight()+"x"+candidate.getRefreshRate()); - - if (candidate.getPhysicalWidth() > 4096 && prefConfig.width <= 4096) { - // Avoid resolutions options above 4K to be safe - continue; - } - - // On non-4K streams, we force the resolution to never change unless it's above - // 60 FPS, which may require a resolution reduction due to HDMI bandwidth limitations, - // or it's a native resolution stream. - if (prefConfig.width < 3840 && prefConfig.fps <= 60 && !isNativeResolutionStream) { - if (display.getMode().getPhysicalWidth() != candidate.getPhysicalWidth() || - display.getMode().getPhysicalHeight() != candidate.getPhysicalHeight()) { - continue; - } - } - - // Make sure the resolution doesn't regress unless if it's over 60 FPS - // where we may need to reduce resolution to achieve the desired refresh rate. - if (resolutionReduced && !(prefConfig.fps > 60 && resolutionFitsStream)) { - continue; - } - - if (mayReduceRefreshRate() && refreshRateIsEqual && !isRefreshRateEqualMatch(candidate.getRefreshRate())) { - // If we had an equal refresh rate and this one is not, skip it. In min latency - // mode, we want to always prefer the highest frame rate even though it may cause - // microstuttering. - continue; - } - else if (refreshRateIsGood) { - // We've already got a good match, so if this one isn't also good, it's not - // worth considering at all. - if (!isRefreshRateGoodMatch(candidate.getRefreshRate())) { - continue; - } - - if (mayReduceRefreshRate()) { - // User asked for the lowest possible refresh rate, so don't raise it if we - // have a good match already - if (candidate.getRefreshRate() > bestMode.getRefreshRate()) { - continue; - } - } - else { - // User asked for the highest possible refresh rate, so don't reduce it if we - // have a good match already - if (refreshRateReduced) { - continue; - } - } - } - else if (!isRefreshRateGoodMatch(candidate.getRefreshRate())) { - // We didn't have a good match and this match isn't good either, so just don't - // reduce the refresh rate. - if (refreshRateReduced) { - continue; - } - } else { - // We didn't have a good match and this match is good. Prefer this refresh rate - // even if it reduces the refresh rate. Lowering the refresh rate can be beneficial - // when streaming a 60 FPS stream on a 90 Hz device. We want to select 60 Hz to - // match the frame rate even if the active display mode is 90 Hz. - } - - bestMode = candidate; - refreshRateIsGood = isRefreshRateGoodMatch(candidate.getRefreshRate()); - refreshRateIsEqual = isRefreshRateEqualMatch(candidate.getRefreshRate()); - } - - LimeLog.info("Best display mode: "+bestMode.getPhysicalWidth()+"x"+ - bestMode.getPhysicalHeight()+"x"+bestMode.getRefreshRate()); - - // Only apply new window layout parameters if we've actually changed the display mode - if (display.getMode().getModeId() != bestMode.getModeId()) { - // If we only changed refresh rate and we're on an OS that supports Surface.setFrameRate() - // use that instead of using preferredDisplayModeId to avoid the possibility of triggering - // bugs that can cause the system to switch from 4K60 to 4K24 on Chromecast 4K. - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || - display.getMode().getPhysicalWidth() != bestMode.getPhysicalWidth() || - display.getMode().getPhysicalHeight() != bestMode.getPhysicalHeight()) { - // Apply the display mode change - windowLayoutParams.preferredDisplayModeId = bestMode.getModeId(); - getWindow().setAttributes(windowLayoutParams); - } - else { - LimeLog.info("Using setFrameRate() instead of preferredDisplayModeId due to matching resolution"); - } - } - else { - LimeLog.info("Current display mode is already the best display mode"); - } - - displayRefreshRate = bestMode.getRefreshRate(); - } - // On L, we can at least tell the OS that we want a refresh rate - else { - float bestRefreshRate = display.getRefreshRate(); - for (float candidate : display.getSupportedRefreshRates()) { - LimeLog.info("Examining refresh rate: "+candidate); - - if (candidate > bestRefreshRate) { - // Ensure the frame rate stays around 60 Hz for <= 60 FPS streams - if (prefConfig.fps <= 60) { - if (candidate >= 63) { - continue; - } - } - - bestRefreshRate = candidate; - } - } - - LimeLog.info("Selected refresh rate: "+bestRefreshRate); - windowLayoutParams.preferredRefreshRate = bestRefreshRate; - displayRefreshRate = bestRefreshRate; - - // Apply the refresh rate change - getWindow().setAttributes(windowLayoutParams); - } - - // Until Marshmallow, we can't ask for a 4K display mode, so we'll - // need to hint the OS to provide one. - boolean aspectRatioMatch = false; - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - // We'll calculate whether we need to scale by aspect ratio. If not, we'll use - // setFixedSize so we can handle 4K properly. The only known devices that have - // >= 4K screens have exactly 4K screens, so we'll be able to hit this good path - // on these devices. On Marshmallow, we can start changing to 4K manually but no - // 4K devices run 6.0 at the moment. - Point screenSize = new Point(0, 0); - display.getSize(screenSize); - - double screenAspectRatio = ((double)screenSize.y) / screenSize.x; - double streamAspectRatio = ((double)prefConfig.height) / prefConfig.width; - if (Math.abs(screenAspectRatio - streamAspectRatio) < 0.001) { - LimeLog.info("Stream has compatible aspect ratio with output display"); - aspectRatioMatch = true; - } - } - - if (prefConfig.stretchVideo || aspectRatioMatch) { - // Set the surface to the size of the video - streamView.getHolder().setFixedSize(prefConfig.width, prefConfig.height); - } - else { - // Set the surface to scale based on the aspect ratio of the stream - streamView.setDesiredAspectRatio((double)prefConfig.width / (double)prefConfig.height); - } - - // Set the desired refresh rate that will get passed into setFrameRate() later - desiredRefreshRate = displayRefreshRate; - - if (getPackageManager().hasSystemFeature(PackageManager.FEATURE_TELEVISION) || - getPackageManager().hasSystemFeature(PackageManager.FEATURE_LEANBACK)) { - // TVs may take a few moments to switch refresh rates, and we can probably assume - // it will be eventually activated. - // TODO: Improve this - return displayRefreshRate; - } - else { - // Use the lower of the current refresh rate and the selected refresh rate. - // The preferred refresh rate may not actually be applied (ex: Battery Saver mode). - return Math.min(getWindowManager().getDefaultDisplay().getRefreshRate(), displayRefreshRate); - } - } - - @SuppressLint("InlinedApi") - private final Runnable hideSystemUi = new Runnable() { - @Override - public void run() { - // TODO: Do we want to use WindowInsetsController here on R+ instead of - // SYSTEM_UI_FLAG_IMMERSIVE_STICKY? They seem to do the same thing as of S... - - // In multi-window mode on N+, we need to drop our layout flags or we'll - // be drawing underneath the system UI. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isInMultiWindowMode()) { - Game.this.getWindow().getDecorView().setSystemUiVisibility( - View.SYSTEM_UI_FLAG_LAYOUT_STABLE); - } - else { - // Use immersive mode - Game.this.getWindow().getDecorView().setSystemUiVisibility( - View.SYSTEM_UI_FLAG_LAYOUT_STABLE | - View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | - View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | - View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | - View.SYSTEM_UI_FLAG_FULLSCREEN | - View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY); - } - } - }; - - private void hideSystemUi(int delay) { - Handler h = getWindow().getDecorView().getHandler(); - if (h != null) { - h.removeCallbacks(hideSystemUi); - h.postDelayed(hideSystemUi, delay); - } - } - - @Override - @TargetApi(Build.VERSION_CODES.N) - public void onMultiWindowModeChanged(boolean isInMultiWindowMode) { - super.onMultiWindowModeChanged(isInMultiWindowMode); - - // In multi-window, we don't want to use the full-screen layout - // flag. It will cause us to collide with the system UI. - // This function will also be called for PiP so we can cover - // that case here too. - if (isInMultiWindowMode) { - getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); - decoderRenderer.notifyVideoBackground(); - } - else { - getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); - decoderRenderer.notifyVideoForeground(); - } - - // Correct the system UI visibility flags - hideSystemUi(50); - } - - @Override - protected void onDestroy() { - super.onDestroy(); - - if (controllerHandler != null) { - controllerHandler.destroy(); - } - if (keyboardTranslator != null) { - InputManager inputManager = (InputManager) getSystemService(Context.INPUT_SERVICE); - inputManager.unregisterInputDeviceListener(keyboardTranslator); - } - - if (lowLatencyWifiLock != null) { - lowLatencyWifiLock.release(); - } - if (highPerfWifiLock != null) { - highPerfWifiLock.release(); - } - - if (connectedToUsbDriverService) { - // Unbind from the discovery service - unbindService(usbDriverServiceConnection); - } - - // Destroy the capture provider - inputCaptureProvider.destroy(); - } - - @Override - protected void onPause() { - if (isFinishing()) { - // Stop any further input device notifications before we lose focus (and pointer capture) - if (controllerHandler != null) { - controllerHandler.stop(); - } - - // Ungrab input to prevent further input device notifications - setInputGrabState(false); - } - - super.onPause(); - } - - @Override - protected void onStop() { - super.onStop(); - - SpinnerDialog.closeDialogs(this); - Dialog.closeDialogs(); - - if (virtualController != null) { - virtualController.hide(); - } - - if (conn != null) { - int videoFormat = decoderRenderer.getActiveVideoFormat(); - - displayedFailureDialog = true; - stopConnection(); - - if (prefConfig.enableLatencyToast) { - int averageEndToEndLat = decoderRenderer.getAverageEndToEndLatency(); - int averageDecoderLat = decoderRenderer.getAverageDecoderLatency(); - String message = null; - if (averageEndToEndLat > 0) { - message = getResources().getString(R.string.conn_client_latency)+" "+averageEndToEndLat+" ms"; - if (averageDecoderLat > 0) { - message += " ("+getResources().getString(R.string.conn_client_latency_hw)+" "+averageDecoderLat+" ms)"; - } - } - else if (averageDecoderLat > 0) { - message = getResources().getString(R.string.conn_hardware_latency)+" "+averageDecoderLat+" ms"; - } - - // Add the video codec to the post-stream toast - if (message != null) { - message += " ["; - - if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_H264) != 0) { - message += "H.264"; - } - else if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_H265) != 0) { - message += "HEVC"; - } - else if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_AV1) != 0) { - message += "AV1"; - } - else { - message += "UNKNOWN"; - } - - if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_10BIT) != 0) { - message += " HDR"; - } - - message += "]"; - } - - if (message != null) { - Toast.makeText(this, message, Toast.LENGTH_LONG).show(); - } - } - - // Clear the tombstone count if we terminated normally - if (!reportedCrash && tombstonePrefs.getInt("CrashCount", 0) != 0) { - tombstonePrefs.edit() - .putInt("CrashCount", 0) - .putInt("LastNotifiedCrashCount", 0) - .apply(); - } - } - - finish(); - } - - private void setInputGrabState(boolean grab) { - // Grab/ungrab the mouse cursor - if (grab) { - inputCaptureProvider.enableCapture(); - - // Enabling capture may hide the cursor again, so - // we will need to show it again. - if (cursorVisible) { - inputCaptureProvider.showCursor(); - } - } - else { - inputCaptureProvider.disableCapture(); - } - - // Grab/ungrab system keyboard shortcuts - setMetaKeyCaptureState(grab); - - grabbedInput = grab; - } - - private final Runnable toggleGrab = new Runnable() { - @Override - public void run() { - setInputGrabState(!grabbedInput); - } - }; - - // Returns true if the key stroke was consumed - private boolean handleSpecialKeys(int androidKeyCode, boolean down) { - int modifierMask = 0; - int nonModifierKeyCode = KeyEvent.KEYCODE_UNKNOWN; - - if (androidKeyCode == KeyEvent.KEYCODE_CTRL_LEFT || - androidKeyCode == KeyEvent.KEYCODE_CTRL_RIGHT) { - modifierMask = KeyboardPacket.MODIFIER_CTRL; - } - else if (androidKeyCode == KeyEvent.KEYCODE_SHIFT_LEFT || - androidKeyCode == KeyEvent.KEYCODE_SHIFT_RIGHT) { - modifierMask = KeyboardPacket.MODIFIER_SHIFT; - } - else if (androidKeyCode == KeyEvent.KEYCODE_ALT_LEFT || - androidKeyCode == KeyEvent.KEYCODE_ALT_RIGHT) { - modifierMask = KeyboardPacket.MODIFIER_ALT; - } - else if (androidKeyCode == KeyEvent.KEYCODE_META_LEFT || - androidKeyCode == KeyEvent.KEYCODE_META_RIGHT) { - modifierMask = KeyboardPacket.MODIFIER_META; - } - else { - nonModifierKeyCode = androidKeyCode; - } - - if (down) { - this.modifierFlags |= modifierMask; - } - else { - this.modifierFlags &= ~modifierMask; - } - - // Handle the special combos on the key up - if (waitingForAllModifiersUp || specialKeyCode != KeyEvent.KEYCODE_UNKNOWN) { - if (specialKeyCode == androidKeyCode) { - // If this is a key up for the special key itself, eat that because the host never saw the original key down - return true; - } - else if (modifierFlags != 0) { - // While we're waiting for modifiers to come up, eat all key downs and allow all key ups to pass - return down; - } - else { - // When all modifiers are up, perform the special action - switch (specialKeyCode) { - // Toggle input grab - case KeyEvent.KEYCODE_Z: - Handler h = getWindow().getDecorView().getHandler(); - if (h != null) { - h.postDelayed(toggleGrab, 250); - } - break; - - // Quit - case KeyEvent.KEYCODE_Q: - finish(); - break; - - // Toggle cursor visibility - case KeyEvent.KEYCODE_C: - if (!grabbedInput) { - inputCaptureProvider.enableCapture(); - grabbedInput = true; - } - cursorVisible = !cursorVisible; - if (cursorVisible) { - inputCaptureProvider.showCursor(); - } else { - inputCaptureProvider.hideCursor(); - } - break; - - default: - break; - } - - // Reset special key state - specialKeyCode = KeyEvent.KEYCODE_UNKNOWN; - waitingForAllModifiersUp = false; - } - } - // Check if Ctrl+Alt+Shift is down when a non-modifier key is pressed - else if ((modifierFlags & (KeyboardPacket.MODIFIER_CTRL | KeyboardPacket.MODIFIER_ALT | KeyboardPacket.MODIFIER_SHIFT)) == - (KeyboardPacket.MODIFIER_CTRL | KeyboardPacket.MODIFIER_ALT | KeyboardPacket.MODIFIER_SHIFT) && - (down && nonModifierKeyCode != KeyEvent.KEYCODE_UNKNOWN)) { - switch (androidKeyCode) { - case KeyEvent.KEYCODE_Z: - case KeyEvent.KEYCODE_Q: - case KeyEvent.KEYCODE_C: - // Remember that a special key combo was activated, so we can consume all key - // events until the modifiers come up - specialKeyCode = androidKeyCode; - waitingForAllModifiersUp = true; - return true; - - default: - // This isn't a special combo that we consume on the client side - return false; - } - } - - // Not a special combo - return false; - } - - // We cannot simply use modifierFlags for all key event processing, because - // some IMEs will not generate real key events for pressing Shift. Instead - // they will simply send key events with isShiftPressed() returning true, - // and we will need to send the modifier flag ourselves. - private byte getModifierState(KeyEvent event) { - // Start with the global modifier state to ensure we cover the case - // detailed in https://github.com/moonlight-stream/moonlight-android/issues/840 - byte modifier = getModifierState(); - if (event.isShiftPressed()) { - modifier |= KeyboardPacket.MODIFIER_SHIFT; - } - if (event.isCtrlPressed()) { - modifier |= KeyboardPacket.MODIFIER_CTRL; - } - if (event.isAltPressed()) { - modifier |= KeyboardPacket.MODIFIER_ALT; - } - if (event.isMetaPressed()) { - modifier |= KeyboardPacket.MODIFIER_META; - } - return modifier; - } - - private byte getModifierState() { - return (byte) modifierFlags; - } - - @Override - public boolean onKeyDown(int keyCode, KeyEvent event) { - return handleKeyDown(event) || super.onKeyDown(keyCode, event); - } - - @Override - public boolean handleKeyDown(KeyEvent event) { - // Pass-through virtual navigation keys - if ((event.getFlags() & KeyEvent.FLAG_VIRTUAL_HARD_KEY) != 0) { - return false; - } - - // Handle a synthetic back button event that some Android OS versions - // create as a result of a right-click. This event WILL repeat if - // the right mouse button is held down, so we ignore those. - int eventSource = event.getSource(); - if ((eventSource == InputDevice.SOURCE_MOUSE || - eventSource == InputDevice.SOURCE_MOUSE_RELATIVE) && - event.getKeyCode() == KeyEvent.KEYCODE_BACK) { - - // Send the right mouse button event if mouse back and forward - // are disabled. If they are enabled, handleMotionEvent() will take - // care of this. - if (!prefConfig.mouseNavButtons) { - conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_RIGHT); - } - - // Always return true, otherwise the back press will be propagated - // up to the parent and finish the activity. - return true; - } - - boolean handled = false; - - if (ControllerHandler.isGameControllerDevice(event.getDevice())) { - // Always try the controller handler first, unless it's an alphanumeric keyboard device. - // Otherwise, controller handler will eat keyboard d-pad events. - handled = controllerHandler.handleButtonDown(event); - } - - // Try the keyboard handler if it wasn't handled as a game controller - if (!handled) { - // Let this method take duplicate key down events - if (handleSpecialKeys(event.getKeyCode(), true)) { - return true; - } - - // Pass through keyboard input if we're not grabbing - if (!grabbedInput) { - return false; - } - - // We'll send it as a raw key event if we have a key mapping, otherwise we'll send it - // as UTF-8 text (if it's a printable character). - short translated = keyboardTranslator.translate(event.getKeyCode(), event.getDeviceId()); - if (translated == 0) { - // Make sure it has a valid Unicode representation and it's not a dead character - // (which we don't support). If those are true, we can send it as UTF-8 text. - // - // NB: We need to be sure this happens before the getRepeatCount() check because - // UTF-8 events don't auto-repeat on the host side. - int unicodeChar = event.getUnicodeChar(); - if ((unicodeChar & KeyCharacterMap.COMBINING_ACCENT) == 0 && (unicodeChar & KeyCharacterMap.COMBINING_ACCENT_MASK) != 0) { - conn.sendUtf8Text(""+(char)unicodeChar); - return true; - } - - return false; - } - - // Eat repeat down events - if (event.getRepeatCount() > 0) { - return true; - } - - conn.sendKeyboardInput(translated, KeyboardPacket.KEY_DOWN, getModifierState(event), - keyboardTranslator.hasNormalizedMapping(event.getKeyCode(), event.getDeviceId()) ? 0 : MoonBridge.SS_KBE_FLAG_NON_NORMALIZED); - } - - return true; - } - - @Override - public boolean onKeyUp(int keyCode, KeyEvent event) { - return handleKeyUp(event) || super.onKeyUp(keyCode, event); - } - - @Override - public boolean handleKeyUp(KeyEvent event) { - // Pass-through virtual navigation keys - if ((event.getFlags() & KeyEvent.FLAG_VIRTUAL_HARD_KEY) != 0) { - return false; - } - - // Handle a synthetic back button event that some Android OS versions - // create as a result of a right-click. - int eventSource = event.getSource(); - if ((eventSource == InputDevice.SOURCE_MOUSE || - eventSource == InputDevice.SOURCE_MOUSE_RELATIVE) && - event.getKeyCode() == KeyEvent.KEYCODE_BACK) { - - // Send the right mouse button event if mouse back and forward - // are disabled. If they are enabled, handleMotionEvent() will take - // care of this. - if (!prefConfig.mouseNavButtons) { - conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_RIGHT); - } - - // Always return true, otherwise the back press will be propagated - // up to the parent and finish the activity. - return true; - } - - boolean handled = false; - if (ControllerHandler.isGameControllerDevice(event.getDevice())) { - // Always try the controller handler first, unless it's an alphanumeric keyboard device. - // Otherwise, controller handler will eat keyboard d-pad events. - handled = controllerHandler.handleButtonUp(event); - } - - // Try the keyboard handler if it wasn't handled as a game controller - if (!handled) { - if (handleSpecialKeys(event.getKeyCode(), false)) { - return true; - } - - // Pass through keyboard input if we're not grabbing - if (!grabbedInput) { - return false; - } - - short translated = keyboardTranslator.translate(event.getKeyCode(), event.getDeviceId()); - if (translated == 0) { - // If we sent this event as UTF-8 on key down, also report that it was handled - // when we get the key up event for it. - int unicodeChar = event.getUnicodeChar(); - return (unicodeChar & KeyCharacterMap.COMBINING_ACCENT) == 0 && (unicodeChar & KeyCharacterMap.COMBINING_ACCENT_MASK) != 0; - } - - conn.sendKeyboardInput(translated, KeyboardPacket.KEY_UP, getModifierState(event), - keyboardTranslator.hasNormalizedMapping(event.getKeyCode(), event.getDeviceId()) ? 0 : MoonBridge.SS_KBE_FLAG_NON_NORMALIZED); - } - - return true; - } - - @Override - public boolean onKeyMultiple(int keyCode, int repeatCount, KeyEvent event) { - return handleKeyMultiple(event) || super.onKeyMultiple(keyCode, repeatCount, event); - } - - private boolean handleKeyMultiple(KeyEvent event) { - // We can receive keys from a software keyboard that don't correspond to any existing - // KEYCODE value. Android will give those to us as an ACTION_MULTIPLE KeyEvent. - // - // Despite the fact that the Android docs say this is unused since API level 29, these - // events are still sent as of Android 13 for the above case. - // - // For other cases of ACTION_MULTIPLE, we will not report those as handled so hopefully - // they will be passed to us again as regular singular key events. - if (event.getKeyCode() != KeyEvent.KEYCODE_UNKNOWN || event.getCharacters() == null) { - return false; - } - - conn.sendUtf8Text(event.getCharacters()); - return true; - } - - private TouchContext getTouchContext(int actionIndex) - { - if (actionIndex < touchContextMap.length) { - return touchContextMap[actionIndex]; - } - else { - return null; - } - } - - @Override - public void toggleKeyboard() { - LimeLog.info("Toggling keyboard overlay"); - InputMethodManager inputManager = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); - inputManager.toggleSoftInput(0, 0); - } - - private byte getLiTouchTypeFromEvent(MotionEvent event) { - switch (event.getActionMasked()) { - case MotionEvent.ACTION_DOWN: - case MotionEvent.ACTION_POINTER_DOWN: - return MoonBridge.LI_TOUCH_EVENT_DOWN; - - case MotionEvent.ACTION_UP: - case MotionEvent.ACTION_POINTER_UP: - if ((event.getFlags() & MotionEvent.FLAG_CANCELED) != 0) { - return MoonBridge.LI_TOUCH_EVENT_CANCEL; - } - else { - return MoonBridge.LI_TOUCH_EVENT_UP; - } - - case MotionEvent.ACTION_MOVE: - return MoonBridge.LI_TOUCH_EVENT_MOVE; - - case MotionEvent.ACTION_CANCEL: - // ACTION_CANCEL applies to *all* pointers in the gesture, so it maps to CANCEL_ALL - // rather than CANCEL. For a single pointer cancellation, that's indicated via - // FLAG_CANCELED on a ACTION_POINTER_UP. - // https://developer.android.com/develop/ui/views/touch-and-input/gestures/multi - return MoonBridge.LI_TOUCH_EVENT_CANCEL_ALL; - - case MotionEvent.ACTION_HOVER_ENTER: - case MotionEvent.ACTION_HOVER_MOVE: - return MoonBridge.LI_TOUCH_EVENT_HOVER; - - case MotionEvent.ACTION_HOVER_EXIT: - return MoonBridge.LI_TOUCH_EVENT_HOVER_LEAVE; - - case MotionEvent.ACTION_BUTTON_PRESS: - case MotionEvent.ACTION_BUTTON_RELEASE: - return MoonBridge.LI_TOUCH_EVENT_BUTTON_ONLY; - - default: - return -1; - } - } - - private float[] getStreamViewRelativeNormalizedXY(View view, MotionEvent event, int pointerIndex) { - float normalizedX = event.getX(pointerIndex); - float normalizedY = event.getY(pointerIndex); - - // For the containing background view, we must subtract the origin - // of the StreamView to get video-relative coordinates. - if (view != streamView) { - normalizedX -= streamView.getX(); - normalizedY -= streamView.getY(); - } - - normalizedX = Math.max(normalizedX, 0.0f); - normalizedY = Math.max(normalizedY, 0.0f); - - normalizedX = Math.min(normalizedX, streamView.getWidth()); - normalizedY = Math.min(normalizedY, streamView.getHeight()); - - normalizedX /= streamView.getWidth(); - normalizedY /= streamView.getHeight(); - - return new float[] { normalizedX, normalizedY }; - } - - private static float normalizeValueInRange(float value, InputDevice.MotionRange range) { - return (value - range.getMin()) / range.getRange(); - } - - private static float getPressureOrDistance(MotionEvent event, int pointerIndex) { - InputDevice dev = event.getDevice(); - switch (event.getActionMasked()) { - case MotionEvent.ACTION_HOVER_ENTER: - case MotionEvent.ACTION_HOVER_MOVE: - case MotionEvent.ACTION_HOVER_EXIT: - // Hover events report distance - if (dev != null) { - InputDevice.MotionRange distanceRange = dev.getMotionRange(MotionEvent.AXIS_DISTANCE, event.getSource()); - if (distanceRange != null) { - return normalizeValueInRange(event.getAxisValue(MotionEvent.AXIS_DISTANCE, pointerIndex), distanceRange); - } - } - return 0.0f; - - default: - // Other events report pressure - return event.getPressure(pointerIndex); - } - } - - private static short getRotationDegrees(MotionEvent event, int pointerIndex) { - InputDevice dev = event.getDevice(); - if (dev != null) { - if (dev.getMotionRange(MotionEvent.AXIS_ORIENTATION, event.getSource()) != null) { - short rotationDegrees = (short) Math.toDegrees(event.getOrientation(pointerIndex)); - if (rotationDegrees < 0) { - rotationDegrees += 360; - } - return rotationDegrees; - } - } - return MoonBridge.LI_ROT_UNKNOWN; - } - - private static float[] polarToCartesian(float r, float theta) { - return new float[] { (float)(r * Math.cos(theta)), (float)(r * Math.sin(theta)) }; - } - - private static float cartesianToR(float[] point) { - return (float)Math.sqrt(Math.pow(point[0], 2) + Math.pow(point[1], 2)); - } - - private float[] getStreamViewNormalizedContactArea(MotionEvent event, int pointerIndex) { - float orientation; - - // If the orientation is unknown, we'll just assume it's at a 45 degree angle and scale it by - // X and Y scaling factors evenly. - if (event.getDevice() == null || event.getDevice().getMotionRange(MotionEvent.AXIS_ORIENTATION, event.getSource()) == null) { - orientation = (float)(Math.PI / 4); - } - else { - orientation = event.getOrientation(pointerIndex); - } - - float contactAreaMajor, contactAreaMinor; - switch (event.getActionMasked()) { - // Hover events report the tool size - case MotionEvent.ACTION_HOVER_ENTER: - case MotionEvent.ACTION_HOVER_MOVE: - case MotionEvent.ACTION_HOVER_EXIT: - contactAreaMajor = event.getToolMajor(pointerIndex); - contactAreaMinor = event.getToolMinor(pointerIndex); - break; - - // Other events report contact area - default: - contactAreaMajor = event.getTouchMajor(pointerIndex); - contactAreaMinor = event.getTouchMinor(pointerIndex); - break; - } - - // The contact area major axis is parallel to the orientation, so we simply convert - // polar to cartesian coordinates using the orientation as theta. - float[] contactAreaMajorCartesian = polarToCartesian(contactAreaMajor, orientation); - - // The contact area minor axis is perpendicular to the contact area major axis (and thus - // the orientation), so rotate the orientation angle by 90 degrees. - float[] contactAreaMinorCartesian = polarToCartesian(contactAreaMinor, (float)(orientation + (Math.PI / 2))); - - // Normalize the contact area to the stream view size - contactAreaMajorCartesian[0] = Math.min(Math.abs(contactAreaMajorCartesian[0]), streamView.getWidth()) / streamView.getWidth(); - contactAreaMinorCartesian[0] = Math.min(Math.abs(contactAreaMinorCartesian[0]), streamView.getWidth()) / streamView.getWidth(); - contactAreaMajorCartesian[1] = Math.min(Math.abs(contactAreaMajorCartesian[1]), streamView.getHeight()) / streamView.getHeight(); - contactAreaMinorCartesian[1] = Math.min(Math.abs(contactAreaMinorCartesian[1]), streamView.getHeight()) / streamView.getHeight(); - - // Convert the normalized values back into polar coordinates - return new float[] { cartesianToR(contactAreaMajorCartesian), cartesianToR(contactAreaMinorCartesian) }; - } - - private boolean sendPenEventForPointer(View view, MotionEvent event, byte eventType, byte toolType, int pointerIndex) { - byte penButtons = 0; - if ((event.getButtonState() & MotionEvent.BUTTON_STYLUS_PRIMARY) != 0) { - penButtons |= MoonBridge.LI_PEN_BUTTON_PRIMARY; - } - if ((event.getButtonState() & MotionEvent.BUTTON_STYLUS_SECONDARY) != 0) { - penButtons |= MoonBridge.LI_PEN_BUTTON_SECONDARY; - } - - byte tiltDegrees = MoonBridge.LI_TILT_UNKNOWN; - InputDevice dev = event.getDevice(); - if (dev != null) { - if (dev.getMotionRange(MotionEvent.AXIS_TILT, event.getSource()) != null) { - tiltDegrees = (byte)Math.toDegrees(event.getAxisValue(MotionEvent.AXIS_TILT, pointerIndex)); - } - } - - float[] normalizedCoords = getStreamViewRelativeNormalizedXY(view, event, pointerIndex); - float[] normalizedContactArea = getStreamViewNormalizedContactArea(event, pointerIndex); - return conn.sendPenEvent(eventType, toolType, penButtons, - normalizedCoords[0], normalizedCoords[1], - getPressureOrDistance(event, pointerIndex), - normalizedContactArea[0], normalizedContactArea[1], - getRotationDegrees(event, pointerIndex), tiltDegrees) != MoonBridge.LI_ERR_UNSUPPORTED; - } - - private static byte convertToolTypeToStylusToolType(MotionEvent event, int pointerIndex) { - switch (event.getToolType(pointerIndex)) { - case MotionEvent.TOOL_TYPE_ERASER: - return MoonBridge.LI_TOOL_TYPE_ERASER; - case MotionEvent.TOOL_TYPE_STYLUS: - return MoonBridge.LI_TOOL_TYPE_PEN; - default: - return MoonBridge.LI_TOOL_TYPE_UNKNOWN; - } - } - - private boolean trySendPenEvent(View view, MotionEvent event) { - byte eventType = getLiTouchTypeFromEvent(event); - if (eventType < 0) { - return false; - } - - if (event.getActionMasked() == MotionEvent.ACTION_MOVE) { - // Move events may impact all active pointers - boolean handledStylusEvent = false; - for (int i = 0; i < event.getPointerCount(); i++) { - byte toolType = convertToolTypeToStylusToolType(event, i); - if (toolType == MoonBridge.LI_TOOL_TYPE_UNKNOWN) { - // Not a stylus pointer, so skip it - continue; - } - else { - // This pointer is a stylus, so we'll report that we handled this event - handledStylusEvent = true; - } - - if (!sendPenEventForPointer(view, event, eventType, toolType, i)) { - // Pen events aren't supported by the host - return false; - } - } - return handledStylusEvent; - } - else if (event.getActionMasked() == MotionEvent.ACTION_CANCEL) { - // Cancel impacts all active pointers - return conn.sendPenEvent(MoonBridge.LI_TOUCH_EVENT_CANCEL_ALL, MoonBridge.LI_TOOL_TYPE_UNKNOWN, (byte)0, - 0, 0, 0, 0, 0, - MoonBridge.LI_ROT_UNKNOWN, MoonBridge.LI_TILT_UNKNOWN) != MoonBridge.LI_ERR_UNSUPPORTED; - } - else { - // Up, Down, and Hover events are specific to the action index - byte toolType = convertToolTypeToStylusToolType(event, event.getActionIndex()); - if (toolType == MoonBridge.LI_TOOL_TYPE_UNKNOWN) { - // Not a stylus event - return false; - } - return sendPenEventForPointer(view, event, eventType, toolType, event.getActionIndex()); - } - } - - private boolean sendTouchEventForPointer(View view, MotionEvent event, byte eventType, int pointerIndex) { - float[] normalizedCoords = getStreamViewRelativeNormalizedXY(view, event, pointerIndex); - float[] normalizedContactArea = getStreamViewNormalizedContactArea(event, pointerIndex); - return conn.sendTouchEvent(eventType, event.getPointerId(pointerIndex), - normalizedCoords[0], normalizedCoords[1], - getPressureOrDistance(event, pointerIndex), - normalizedContactArea[0], normalizedContactArea[1], - getRotationDegrees(event, pointerIndex)) != MoonBridge.LI_ERR_UNSUPPORTED; - } - - private boolean trySendTouchEvent(View view, MotionEvent event) { - byte eventType = getLiTouchTypeFromEvent(event); - if (eventType < 0) { - return false; - } - - if (event.getActionMasked() == MotionEvent.ACTION_MOVE) { - // Move events may impact all active pointers - for (int i = 0; i < event.getPointerCount(); i++) { - if (!sendTouchEventForPointer(view, event, eventType, i)) { - return false; - } - } - return true; - } - else if (event.getActionMasked() == MotionEvent.ACTION_CANCEL) { - // Cancel impacts all active pointers - return conn.sendTouchEvent(MoonBridge.LI_TOUCH_EVENT_CANCEL_ALL, 0, - 0, 0, 0, 0, 0, - MoonBridge.LI_ROT_UNKNOWN) != MoonBridge.LI_ERR_UNSUPPORTED; - } - else { - // Up, Down, and Hover events are specific to the action index - return sendTouchEventForPointer(view, event, eventType, event.getActionIndex()); - } - } - - // Returns true if the event was consumed - // NB: View is only present if called from a view callback - private boolean handleMotionEvent(View view, MotionEvent event) { - // Pass through mouse/touch/joystick input if we're not grabbing - if (!grabbedInput) { - return false; - } - - int eventSource = event.getSource(); - int deviceSources = event.getDevice() != null ? event.getDevice().getSources() : 0; - if ((eventSource & InputDevice.SOURCE_CLASS_JOYSTICK) != 0) { - if (controllerHandler.handleMotionEvent(event)) { - return true; - } - } - else if ((deviceSources & InputDevice.SOURCE_CLASS_JOYSTICK) != 0 && controllerHandler.tryHandleTouchpadEvent(event)) { - return true; - } - else if ((eventSource & InputDevice.SOURCE_CLASS_POINTER) != 0 || - (eventSource & InputDevice.SOURCE_CLASS_POSITION) != 0 || - eventSource == InputDevice.SOURCE_MOUSE_RELATIVE) - { - // This case is for mice and non-finger touch devices - if (eventSource == InputDevice.SOURCE_MOUSE || - (eventSource & InputDevice.SOURCE_CLASS_POSITION) != 0 || // SOURCE_TOUCHPAD - eventSource == InputDevice.SOURCE_MOUSE_RELATIVE || - (event.getPointerCount() >= 1 && - (event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE || - event.getToolType(0) == MotionEvent.TOOL_TYPE_STYLUS || - event.getToolType(0) == MotionEvent.TOOL_TYPE_ERASER)) || - eventSource == 12290) // 12290 = Samsung DeX mode desktop mouse - { - int buttonState = event.getButtonState(); - int changedButtons = buttonState ^ lastButtonState; - - // The DeX touchpad on the Fold 4 sends proper right click events using BUTTON_SECONDARY, - // but doesn't send BUTTON_PRIMARY for a regular click. Instead it sends ACTION_DOWN/UP, - // so we need to fix that up to look like a sane input event to process it correctly. - if (eventSource == 12290) { - if (event.getActionMasked() == MotionEvent.ACTION_DOWN) { - buttonState |= MotionEvent.BUTTON_PRIMARY; - } - else if (event.getAction() == MotionEvent.ACTION_UP) { - buttonState &= ~MotionEvent.BUTTON_PRIMARY; - } - else { - // We may be faking the primary button down from a previous event, - // so be sure to add that bit back into the button state. - buttonState |= (lastButtonState & MotionEvent.BUTTON_PRIMARY); - } - - changedButtons = buttonState ^ lastButtonState; - } - - // Ignore mouse input if we're not capturing from our input source - if (!inputCaptureProvider.isCapturingActive()) { - // We return true here because otherwise the events may end up causing - // Android to synthesize d-pad events. - return true; - } - - // Always update the position before sending any button events. If we're - // dealing with a stylus without hover support, our position might be - // significantly different than before. - if (inputCaptureProvider.eventHasRelativeMouseAxes(event)) { - // Send the deltas straight from the motion event - short deltaX = (short)inputCaptureProvider.getRelativeAxisX(event); - short deltaY = (short)inputCaptureProvider.getRelativeAxisY(event); - - if (deltaX != 0 || deltaY != 0) { - if (prefConfig.absoluteMouseMode) { - // NB: view may be null, but we can unconditionally use streamView because we don't need to adjust - // relative axis deltas for the position of the streamView within the parent's coordinate system. - conn.sendMouseMoveAsMousePosition(deltaX, deltaY, (short)streamView.getWidth(), (short)streamView.getHeight()); - } - else { - conn.sendMouseMove(deltaX, deltaY); - } - } - } - else if ((eventSource & InputDevice.SOURCE_CLASS_POSITION) != 0) { - // If this input device is not associated with the view itself (like a trackpad), - // we'll convert the device-specific coordinates to use to send the cursor position. - // This really isn't ideal but it's probably better than nothing. - // - // Trackpad on newer versions of Android (Oreo and later) should be caught by the - // relative axes case above. If we get here, we're on an older version that doesn't - // support pointer capture. - InputDevice device = event.getDevice(); - if (device != null) { - InputDevice.MotionRange xRange = device.getMotionRange(MotionEvent.AXIS_X, eventSource); - InputDevice.MotionRange yRange = device.getMotionRange(MotionEvent.AXIS_Y, eventSource); - - // All touchpads coordinate planes should start at (0, 0) - if (xRange != null && yRange != null && xRange.getMin() == 0 && yRange.getMin() == 0) { - int xMax = (int)xRange.getMax(); - int yMax = (int)yRange.getMax(); - - // Touchpads must be smaller than (65535, 65535) - if (xMax <= Short.MAX_VALUE && yMax <= Short.MAX_VALUE) { - conn.sendMousePosition((short)event.getX(), (short)event.getY(), - (short)xMax, (short)yMax); - } - } - } - } - else if (view != null && trySendPenEvent(view, event)) { - // If our host supports pen events, send it directly - return true; - } - else if (view != null) { - // Otherwise send absolute position based on the view for SOURCE_CLASS_POINTER - updateMousePosition(view, event); - } - - if (event.getActionMasked() == MotionEvent.ACTION_SCROLL) { - // Send the vertical scroll packet - conn.sendMouseHighResScroll((short)(event.getAxisValue(MotionEvent.AXIS_VSCROLL) * 120)); - conn.sendMouseHighResHScroll((short)(event.getAxisValue(MotionEvent.AXIS_HSCROLL) * 120)); - } - - if ((changedButtons & MotionEvent.BUTTON_PRIMARY) != 0) { - if ((buttonState & MotionEvent.BUTTON_PRIMARY) != 0) { - conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_LEFT); - } - else { - conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT); - } - } - - // Mouse secondary or stylus primary is right click (stylus down is left click) - if ((changedButtons & (MotionEvent.BUTTON_SECONDARY | MotionEvent.BUTTON_STYLUS_PRIMARY)) != 0) { - if ((buttonState & (MotionEvent.BUTTON_SECONDARY | MotionEvent.BUTTON_STYLUS_PRIMARY)) != 0) { - conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_RIGHT); - } - else { - conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_RIGHT); - } - } - - // Mouse tertiary or stylus secondary is middle click - if ((changedButtons & (MotionEvent.BUTTON_TERTIARY | MotionEvent.BUTTON_STYLUS_SECONDARY)) != 0) { - if ((buttonState & (MotionEvent.BUTTON_TERTIARY | MotionEvent.BUTTON_STYLUS_SECONDARY)) != 0) { - conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_MIDDLE); - } - else { - conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_MIDDLE); - } - } - - if (prefConfig.mouseNavButtons) { - if ((changedButtons & MotionEvent.BUTTON_BACK) != 0) { - if ((buttonState & MotionEvent.BUTTON_BACK) != 0) { - conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_X1); - } - else { - conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_X1); - } - } - - if ((changedButtons & MotionEvent.BUTTON_FORWARD) != 0) { - if ((buttonState & MotionEvent.BUTTON_FORWARD) != 0) { - conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_X2); - } - else { - conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_X2); - } - } - } - - // Handle stylus presses - if (event.getPointerCount() == 1 && event.getActionIndex() == 0) { - if (event.getActionMasked() == MotionEvent.ACTION_DOWN) { - if (event.getToolType(0) == MotionEvent.TOOL_TYPE_STYLUS) { - lastAbsTouchDownTime = event.getEventTime(); - lastAbsTouchDownX = event.getX(0); - lastAbsTouchDownY = event.getY(0); - - // Stylus is left click - conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_LEFT); - } else if (event.getToolType(0) == MotionEvent.TOOL_TYPE_ERASER) { - lastAbsTouchDownTime = event.getEventTime(); - lastAbsTouchDownX = event.getX(0); - lastAbsTouchDownY = event.getY(0); - - // Eraser is right click - conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_RIGHT); - } - } - else if (event.getActionMasked() == MotionEvent.ACTION_UP || event.getActionMasked() == MotionEvent.ACTION_CANCEL) { - if (event.getToolType(0) == MotionEvent.TOOL_TYPE_STYLUS) { - lastAbsTouchUpTime = event.getEventTime(); - lastAbsTouchUpX = event.getX(0); - lastAbsTouchUpY = event.getY(0); - - // Stylus is left click - conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT); - } else if (event.getToolType(0) == MotionEvent.TOOL_TYPE_ERASER) { - lastAbsTouchUpTime = event.getEventTime(); - lastAbsTouchUpX = event.getX(0); - lastAbsTouchUpY = event.getY(0); - - // Eraser is right click - conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_RIGHT); - } - } - } - - lastButtonState = buttonState; - } - // This case is for fingers - else - { - if (virtualController != null && - (virtualController.getControllerMode() == VirtualController.ControllerMode.MoveButtons || - virtualController.getControllerMode() == VirtualController.ControllerMode.ResizeButtons)) { - // Ignore presses when the virtual controller is being configured - return true; - } - - // If this is the parent view, we'll offset our coordinates to appear as if they - // are relative to the StreamView like our StreamView touch events are. - float xOffset, yOffset; - if (view != streamView && !prefConfig.touchscreenTrackpad) { - xOffset = -streamView.getX(); - yOffset = -streamView.getY(); - } - else { - xOffset = 0.f; - yOffset = 0.f; - } - - int actionIndex = event.getActionIndex(); - - int eventX = (int)(event.getX(actionIndex) + xOffset); - int eventY = (int)(event.getY(actionIndex) + yOffset); - - // Special handling for 3 finger gesture - if (event.getActionMasked() == MotionEvent.ACTION_POINTER_DOWN && - event.getPointerCount() == 3) { - // Three fingers down - threeFingerDownTime = event.getEventTime(); - - // Cancel the first and second touches to avoid - // erroneous events - for (TouchContext aTouchContext : touchContextMap) { - aTouchContext.cancelTouch(); - } - - return true; - } - - // TODO: Re-enable native touch when have a better solution for handling - // cancelled touches from Android gestures and 3 finger taps to activate - // the software keyboard. - /*if (!prefConfig.touchscreenTrackpad && trySendTouchEvent(view, event)) { - // If this host supports touch events and absolute touch is enabled, - // send it directly as a touch event. - return true; - }*/ - - TouchContext context = getTouchContext(actionIndex); - if (context == null) { - return false; - } - - switch (event.getActionMasked()) - { - case MotionEvent.ACTION_POINTER_DOWN: - case MotionEvent.ACTION_DOWN: - for (TouchContext touchContext : touchContextMap) { - touchContext.setPointerCount(event.getPointerCount()); - } - context.touchDownEvent(eventX, eventY, event.getEventTime(), true); - break; - case MotionEvent.ACTION_POINTER_UP: - case MotionEvent.ACTION_UP: - if (event.getPointerCount() == 1 && - (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || (event.getFlags() & MotionEvent.FLAG_CANCELED) == 0)) { - // All fingers up - if (event.getEventTime() - threeFingerDownTime < THREE_FINGER_TAP_THRESHOLD) { - // This is a 3 finger tap to bring up the keyboard - toggleKeyboard(); - return true; - } - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && (event.getFlags() & MotionEvent.FLAG_CANCELED) != 0) { - context.cancelTouch(); - } - else { - context.touchUpEvent(eventX, eventY, event.getEventTime()); - } - - for (TouchContext touchContext : touchContextMap) { - touchContext.setPointerCount(event.getPointerCount() - 1); - } - if (actionIndex == 0 && event.getPointerCount() > 1 && !context.isCancelled()) { - // The original secondary touch now becomes primary - context.touchDownEvent( - (int)(event.getX(1) + xOffset), - (int)(event.getY(1) + yOffset), - event.getEventTime(), false); - } - break; - case MotionEvent.ACTION_MOVE: - // ACTION_MOVE is special because it always has actionIndex == 0 - // We'll call the move handlers for all indexes manually - - // First process the historical events - for (int i = 0; i < event.getHistorySize(); i++) { - for (TouchContext aTouchContextMap : touchContextMap) { - if (aTouchContextMap.getActionIndex() < event.getPointerCount()) - { - aTouchContextMap.touchMoveEvent( - (int)(event.getHistoricalX(aTouchContextMap.getActionIndex(), i) + xOffset), - (int)(event.getHistoricalY(aTouchContextMap.getActionIndex(), i) + yOffset), - event.getHistoricalEventTime(i)); - } - } - } - - // Now process the current values - for (TouchContext aTouchContextMap : touchContextMap) { - if (aTouchContextMap.getActionIndex() < event.getPointerCount()) - { - aTouchContextMap.touchMoveEvent( - (int)(event.getX(aTouchContextMap.getActionIndex()) + xOffset), - (int)(event.getY(aTouchContextMap.getActionIndex()) + yOffset), - event.getEventTime()); - } - } - break; - case MotionEvent.ACTION_CANCEL: - for (TouchContext aTouchContext : touchContextMap) { - aTouchContext.cancelTouch(); - aTouchContext.setPointerCount(0); - } - break; - default: - return false; - } - } - - // Handled a known source - return true; - } - - // Unknown class - return false; - } - - @Override - public boolean onGenericMotionEvent(MotionEvent event) { - return handleMotionEvent(null, event) || super.onGenericMotionEvent(event); - - } - - private void updateMousePosition(View touchedView, MotionEvent event) { - // X and Y are already relative to the provided view object - float eventX, eventY; - - // For our StreamView itself, we can use the coordinates unmodified. - if (touchedView == streamView) { - eventX = event.getX(0); - eventY = event.getY(0); - } - else { - // For the containing background view, we must subtract the origin - // of the StreamView to get video-relative coordinates. - eventX = event.getX(0) - streamView.getX(); - eventY = event.getY(0) - streamView.getY(); - } - - if (event.getPointerCount() == 1 && event.getActionIndex() == 0 && - (event.getToolType(0) == MotionEvent.TOOL_TYPE_ERASER || - event.getToolType(0) == MotionEvent.TOOL_TYPE_STYLUS)) - { - switch (event.getActionMasked()) { - case MotionEvent.ACTION_DOWN: - case MotionEvent.ACTION_HOVER_ENTER: - case MotionEvent.ACTION_HOVER_EXIT: - case MotionEvent.ACTION_HOVER_MOVE: - if (event.getEventTime() - lastAbsTouchUpTime <= STYLUS_UP_DEAD_ZONE_DELAY && - Math.sqrt(Math.pow(eventX - lastAbsTouchUpX, 2) + Math.pow(eventY - lastAbsTouchUpY, 2)) <= STYLUS_UP_DEAD_ZONE_RADIUS) { - // Enforce a small deadzone between touch up and hover or touch down to allow more precise double-clicking - return; - } - break; - - case MotionEvent.ACTION_MOVE: - case MotionEvent.ACTION_UP: - if (event.getEventTime() - lastAbsTouchDownTime <= STYLUS_DOWN_DEAD_ZONE_DELAY && - Math.sqrt(Math.pow(eventX - lastAbsTouchDownX, 2) + Math.pow(eventY - lastAbsTouchDownY, 2)) <= STYLUS_DOWN_DEAD_ZONE_RADIUS) { - // Enforce a small deadzone between touch down and move or touch up to allow more precise double-clicking - return; - } - break; - } - } - - // We may get values slightly outside our view region on ACTION_HOVER_ENTER and ACTION_HOVER_EXIT. - // Normalize these to the view size. We can't just drop them because we won't always get an event - // right at the boundary of the view, so dropping them would result in our cursor never really - // reaching the sides of the screen. - eventX = Math.min(Math.max(eventX, 0), streamView.getWidth()); - eventY = Math.min(Math.max(eventY, 0), streamView.getHeight()); - - conn.sendMousePosition((short)eventX, (short)eventY, (short)streamView.getWidth(), (short)streamView.getHeight()); - } - - @Override - public boolean onGenericMotion(View view, MotionEvent event) { - return handleMotionEvent(view, event); - } - - @SuppressLint("ClickableViewAccessibility") - @Override - public boolean onTouch(View view, MotionEvent event) { - if (event.getAction() == MotionEvent.ACTION_DOWN) { - // Tell the OS not to buffer input events for us - // - // NB: This is still needed even when we call the newer requestUnbufferedDispatch()! - view.requestUnbufferedDispatch(event); - } - - return handleMotionEvent(view, event); - } - - @Override - public void stageStarting(final String stage) { - runOnUiThread(new Runnable() { - @Override - public void run() { - if (spinner != null) { - spinner.setMessage(getResources().getString(R.string.conn_starting) + " " + stage); - } - } - }); - } - - @Override - public void stageComplete(String stage) { - } - - private void stopConnection() { - if (connecting || connected) { - connecting = connected = false; - updatePipAutoEnter(); - - controllerHandler.stop(); - - // Update GameManager state to indicate we're no longer in game - UiHelper.notifyStreamEnded(this); - - // Stop may take a few hundred ms to do some network I/O to tell - // the server we're going away and clean up. Let it run in a separate - // thread to keep things smooth for the UI. Inside moonlight-common, - // we prevent another thread from starting a connection before and - // during the process of stopping this one. - new Thread() { - public void run() { - conn.stop(); - } - }.start(); - } - } - - @Override - public void stageFailed(final String stage, final int portFlags, final int errorCode) { - // Perform a connection test if the failure could be due to a blocked port - // This does network I/O, so don't do it on the main thread. - final int portTestResult = MoonBridge.testClientConnectivity(ServerHelper.CONNECTION_TEST_SERVER, 443, portFlags); - - runOnUiThread(new Runnable() { - @Override - public void run() { - if (spinner != null) { - spinner.dismiss(); - spinner = null; - } - - if (!displayedFailureDialog) { - displayedFailureDialog = true; - LimeLog.severe(stage + " failed: " + errorCode); - - // If video initialization failed and the surface is still valid, display extra information for the user - if (stage.contains("video") && streamView.getHolder().getSurface().isValid()) { - Toast.makeText(Game.this, getResources().getText(R.string.video_decoder_init_failed), Toast.LENGTH_LONG).show(); - } - - String dialogText = getResources().getString(R.string.conn_error_msg) + " " + stage +" (error "+errorCode+")"; - - if (portFlags != 0) { - dialogText += "\n\n" + getResources().getString(R.string.check_ports_msg) + "\n" + - MoonBridge.stringifyPortFlags(portFlags, "\n"); - } - - if (portTestResult != MoonBridge.ML_TEST_RESULT_INCONCLUSIVE && portTestResult != 0) { - dialogText += "\n\n" + getResources().getString(R.string.nettest_text_blocked); - } - - Dialog.displayDialog(Game.this, getResources().getString(R.string.conn_error_title), dialogText, true); - } - } - }); - } - - @Override - public void connectionTerminated(final int errorCode) { - // Perform a connection test if the failure could be due to a blocked port - // This does network I/O, so don't do it on the main thread. - final int portFlags = MoonBridge.getPortFlagsFromTerminationErrorCode(errorCode); - final int portTestResult = MoonBridge.testClientConnectivity(ServerHelper.CONNECTION_TEST_SERVER,443, portFlags); - - runOnUiThread(new Runnable() { - @Override - public void run() { - // Let the display go to sleep now - getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); - - // Stop processing controller input - controllerHandler.stop(); - - // Ungrab input - setInputGrabState(false); - - if (!displayedFailureDialog) { - displayedFailureDialog = true; - LimeLog.severe("Connection terminated: " + errorCode); - stopConnection(); - - // Display the error dialog if it was an unexpected termination. - // Otherwise, just finish the activity immediately. - if (errorCode != MoonBridge.ML_ERROR_GRACEFUL_TERMINATION) { - String message; - - if (portTestResult != MoonBridge.ML_TEST_RESULT_INCONCLUSIVE && portTestResult != 0) { - // If we got a blocked result, that supersedes any other error message - message = getResources().getString(R.string.nettest_text_blocked); - } - else { - switch (errorCode) { - case MoonBridge.ML_ERROR_NO_VIDEO_TRAFFIC: - message = getResources().getString(R.string.no_video_received_error); - break; - - case MoonBridge.ML_ERROR_NO_VIDEO_FRAME: - message = getResources().getString(R.string.no_frame_received_error); - break; - - case MoonBridge.ML_ERROR_UNEXPECTED_EARLY_TERMINATION: - case MoonBridge.ML_ERROR_PROTECTED_CONTENT: - message = getResources().getString(R.string.early_termination_error); - break; - - case MoonBridge.ML_ERROR_FRAME_CONVERSION: - message = getResources().getString(R.string.frame_conversion_error); - break; - - default: - String errorCodeString; - // We'll assume large errors are hex values - if (Math.abs(errorCode) > 1000) { - errorCodeString = Integer.toHexString(errorCode); - } - else { - errorCodeString = Integer.toString(errorCode); - } - message = getResources().getString(R.string.conn_terminated_msg) + "\n\n" + - getResources().getString(R.string.error_code_prefix) + " " + errorCodeString; - break; - } - } - - if (portFlags != 0) { - message += "\n\n" + getResources().getString(R.string.check_ports_msg) + "\n" + - MoonBridge.stringifyPortFlags(portFlags, "\n"); - } - - Dialog.displayDialog(Game.this, getResources().getString(R.string.conn_terminated_title), - message, true); - } - else { - finish(); - } - } - } - }); - } - - @Override - public void connectionStatusUpdate(final int connectionStatus) { - runOnUiThread(new Runnable() { - @Override - public void run() { - if (prefConfig.disableWarnings) { - return; - } - - if (connectionStatus == MoonBridge.CONN_STATUS_POOR) { - if (prefConfig.bitrate > 5000) { - notificationOverlayView.setText(getResources().getString(R.string.slow_connection_msg)); - } - else { - notificationOverlayView.setText(getResources().getString(R.string.poor_connection_msg)); - } - - requestedNotificationOverlayVisibility = View.VISIBLE; - } - else if (connectionStatus == MoonBridge.CONN_STATUS_OKAY) { - requestedNotificationOverlayVisibility = View.GONE; - } - - if (!isHidingOverlays) { - notificationOverlayView.setVisibility(requestedNotificationOverlayVisibility); - } - } - }); - } - - @Override - public void connectionStarted() { - runOnUiThread(new Runnable() { - @Override - public void run() { - if (spinner != null) { - spinner.dismiss(); - spinner = null; - } - - connected = true; - connecting = false; - updatePipAutoEnter(); - - // Hide the mouse cursor now after a short delay. - // Doing it before dismissing the spinner seems to be undone - // when the spinner gets displayed. On Android Q, even now - // is too early to capture. We will delay a second to allow - // the spinner to dismiss before capturing. - Handler h = new Handler(); - h.postDelayed(new Runnable() { - @Override - public void run() { - setInputGrabState(true); - } - }, 500); - - // Keep the display on - getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); - - // Update GameManager state to indicate we're in game - UiHelper.notifyStreamConnected(Game.this); - - hideSystemUi(1000); - } - }); - - // Report this shortcut being used (off the main thread to prevent ANRs) - ComputerDetails computer = new ComputerDetails(); - computer.name = pcName; - computer.uuid = Game.this.getIntent().getStringExtra(EXTRA_PC_UUID); - ShortcutHelper shortcutHelper = new ShortcutHelper(this); - shortcutHelper.reportComputerShortcutUsed(computer); - if (appName != null) { - // This may be null if launched from the "Resume Session" PC context menu item - shortcutHelper.reportGameLaunched(computer, app); - } - } - - @Override - public void displayMessage(final String message) { - runOnUiThread(new Runnable() { - @Override - public void run() { - Toast.makeText(Game.this, message, Toast.LENGTH_LONG).show(); - } - }); - } - - @Override - public void displayTransientMessage(final String message) { - if (!prefConfig.disableWarnings) { - runOnUiThread(new Runnable() { - @Override - public void run() { - Toast.makeText(Game.this, message, Toast.LENGTH_LONG).show(); - } - }); - } - } - - @Override - public void rumble(short controllerNumber, short lowFreqMotor, short highFreqMotor) { - LimeLog.info(String.format((Locale)null, "Rumble on gamepad %d: %04x %04x", controllerNumber, lowFreqMotor, highFreqMotor)); - - controllerHandler.handleRumble(controllerNumber, lowFreqMotor, highFreqMotor); - } - - @Override - public void rumbleTriggers(short controllerNumber, short leftTrigger, short rightTrigger) { - LimeLog.info(String.format((Locale)null, "Rumble on gamepad triggers %d: %04x %04x", controllerNumber, leftTrigger, rightTrigger)); - - controllerHandler.handleRumbleTriggers(controllerNumber, leftTrigger, rightTrigger); - } - - @Override - public void setHdrMode(boolean enabled, byte[] hdrMetadata) { - LimeLog.info("Display HDR mode: " + (enabled ? "enabled" : "disabled")); - decoderRenderer.setHdrMode(enabled, hdrMetadata); - } - - @Override - public void setMotionEventState(short controllerNumber, byte motionType, short reportRateHz) { - controllerHandler.handleSetMotionEventState(controllerNumber, motionType, reportRateHz); - } - - @Override - public void setControllerLED(short controllerNumber, byte r, byte g, byte b) { - controllerHandler.handleSetControllerLED(controllerNumber, r, g, b); - } - - @Override - public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { - if (!surfaceCreated) { - throw new IllegalStateException("Surface changed before creation!"); - } - - if (!attemptedConnection) { - attemptedConnection = true; - - // Update GameManager state to indicate we're "loading" while connecting - UiHelper.notifyStreamConnecting(Game.this); - - decoderRenderer.setRenderTarget(holder); - conn.start(new AndroidAudioRenderer(Game.this, prefConfig.enableAudioFx), - decoderRenderer, Game.this); - } - } - - @Override - public void surfaceCreated(SurfaceHolder holder) { - float desiredFrameRate; - - surfaceCreated = true; - - // Android will pick the lowest matching refresh rate for a given frame rate value, so we want - // to report the true FPS value if refresh rate reduction is enabled. We also report the true - // FPS value if there's no suitable matching refresh rate. In that case, Android could try to - // select a lower refresh rate that avoids uneven pull-down (ex: 30 Hz for a 60 FPS stream on - // a display that maxes out at 50 Hz). - if (mayReduceRefreshRate() || desiredRefreshRate < prefConfig.fps) { - desiredFrameRate = prefConfig.fps; - } - else { - // Otherwise, we will pretend that our frame rate matches the refresh rate we picked in - // prepareDisplayForRendering(). This will usually be the highest refresh rate that our - // frame rate evenly divides into, which ensures the lowest possible display latency. - desiredFrameRate = desiredRefreshRate; - } - - // Tell the OS about our frame rate to allow it to adapt the display refresh rate appropriately - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - // We want to change frame rate even if it's not seamless, since prepareDisplayForRendering() - // will not set the display mode on S+ if it only differs by the refresh rate. It depends - // on us to trigger the frame rate switch here. - holder.getSurface().setFrameRate(desiredFrameRate, - Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE, - Surface.CHANGE_FRAME_RATE_ALWAYS); - } - else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - holder.getSurface().setFrameRate(desiredFrameRate, - Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE); - } - } - - @Override - public void surfaceDestroyed(SurfaceHolder holder) { - if (!surfaceCreated) { - throw new IllegalStateException("Surface destroyed before creation!"); - } - - if (attemptedConnection) { - // Let the decoder know immediately that the surface is gone - decoderRenderer.prepareForStop(); - - if (connected) { - stopConnection(); - } - } - } - - @Override - public void mouseMove(int deltaX, int deltaY) { - conn.sendMouseMove((short) deltaX, (short) deltaY); - } - - @Override - public void mouseButtonEvent(int buttonId, boolean down) { - byte buttonIndex; - - switch (buttonId) - { - case EvdevListener.BUTTON_LEFT: - buttonIndex = MouseButtonPacket.BUTTON_LEFT; - break; - case EvdevListener.BUTTON_MIDDLE: - buttonIndex = MouseButtonPacket.BUTTON_MIDDLE; - break; - case EvdevListener.BUTTON_RIGHT: - buttonIndex = MouseButtonPacket.BUTTON_RIGHT; - break; - case EvdevListener.BUTTON_X1: - buttonIndex = MouseButtonPacket.BUTTON_X1; - break; - case EvdevListener.BUTTON_X2: - buttonIndex = MouseButtonPacket.BUTTON_X2; - break; - default: - LimeLog.warning("Unhandled button: "+buttonId); - return; - } - - if (down) { - conn.sendMouseButtonDown(buttonIndex); - } - else { - conn.sendMouseButtonUp(buttonIndex); - } - } - - @Override - public void mouseVScroll(byte amount) { - conn.sendMouseScroll(amount); - } - - @Override - public void mouseHScroll(byte amount) { - conn.sendMouseHScroll(amount); - } - - @Override - public void keyboardEvent(boolean buttonDown, short keyCode) { - short keyMap = keyboardTranslator.translate(keyCode, -1); - if (keyMap != 0) { - // handleSpecialKeys() takes the Android keycode - if (handleSpecialKeys(keyCode, buttonDown)) { - return; - } - - if (buttonDown) { - conn.sendKeyboardInput(keyMap, KeyboardPacket.KEY_DOWN, getModifierState(), (byte)0); - } - else { - conn.sendKeyboardInput(keyMap, KeyboardPacket.KEY_UP, getModifierState(), (byte)0); - } - } - } - - @Override - public void onSystemUiVisibilityChange(int visibility) { - // Don't do anything if we're not connected - if (!connected) { - return; - } - - // This flag is set for all devices - if ((visibility & View.SYSTEM_UI_FLAG_FULLSCREEN) == 0) { - hideSystemUi(2000); - } - else if ((visibility & View.SYSTEM_UI_FLAG_HIDE_NAVIGATION) == 0) { - hideSystemUi(2000); - } - } - - @Override - public void onPerfUpdate(final String text) { - runOnUiThread(new Runnable() { - @Override - public void run() { - performanceOverlayView.setText(text); - } - }); - } - - @Override - public void onUsbPermissionPromptStarting() { - // Disable PiP auto-enter while the USB permission prompt is on-screen. This prevents - // us from entering PiP while the user is interacting with the OS permission dialog. - suppressPipRefCount++; - updatePipAutoEnter(); - } - - @Override - public void onUsbPermissionPromptCompleted() { - suppressPipRefCount--; - updatePipAutoEnter(); - } - - @Override - public boolean onKey(View view, int keyCode, KeyEvent keyEvent) { - switch (keyEvent.getAction()) { - case KeyEvent.ACTION_DOWN: - return handleKeyDown(keyEvent); - case KeyEvent.ACTION_UP: - return handleKeyUp(keyEvent); - case KeyEvent.ACTION_MULTIPLE: - return handleKeyMultiple(keyEvent); - default: - return false; - } - } -} +package com.limelight; + + +import com.limelight.binding.PlatformBinding; +import com.limelight.binding.audio.AndroidAudioRenderer; +import com.limelight.binding.input.ControllerHandler; +import com.limelight.binding.input.GameInputDevice; +import com.limelight.binding.input.KeyboardTranslator; +import com.limelight.binding.input.capture.InputCaptureManager; +import com.limelight.binding.input.capture.InputCaptureProvider; +import com.limelight.binding.input.touch.AbsoluteTouchContext; +import com.limelight.binding.input.touch.AbsoluteTouchSwitchContext; +import com.limelight.binding.input.touch.RelativeTouchContext; +import com.limelight.binding.input.driver.UsbDriverService; +import com.limelight.binding.input.evdev.EvdevListener; +import com.limelight.binding.input.touch.TouchContext; +import com.limelight.binding.input.virtual_controller.VirtualController; +import com.limelight.binding.input.virtual_controller.keyboard.KeyBoardController; +import com.limelight.binding.input.virtual_controller.keyboard.KeyBoardLayoutController; +import com.limelight.binding.video.CrashListener; +import com.limelight.binding.video.MediaCodecDecoderRenderer; +import com.limelight.binding.video.MediaCodecHelper; +import com.limelight.binding.video.PerfOverlayListener; +import com.limelight.nvstream.NvConnection; +import com.limelight.nvstream.NvConnectionListener; +import com.limelight.nvstream.StreamConfiguration; +import com.limelight.nvstream.http.ComputerDetails; +import com.limelight.nvstream.http.NvApp; +import com.limelight.nvstream.http.NvHTTP; +import com.limelight.nvstream.input.KeyboardPacket; +import com.limelight.nvstream.input.MouseButtonPacket; +import com.limelight.nvstream.jni.MoonBridge; +import com.limelight.preferences.GlPreferences; +import com.limelight.preferences.PreferenceConfiguration; +import com.limelight.ui.GameGestures; +import com.limelight.ui.StreamView; +import com.limelight.utils.Dialog; +import com.limelight.utils.ServerHelper; +import com.limelight.utils.ShortcutHelper; +import com.limelight.utils.SpinnerDialog; +import com.limelight.utils.UiHelper; +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.app.Activity; +import android.app.AlertDialog; +import android.app.PictureInPictureParams; +import android.app.Service; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.content.SharedPreferences; +import android.content.pm.ActivityInfo; +import android.content.pm.PackageManager; +import android.content.res.Configuration; +import android.graphics.Outline; +import android.graphics.Point; +import android.graphics.Rect; +import android.hardware.display.DisplayManager; +import android.hardware.input.InputManager; +import android.media.AudioManager; +import android.net.ConnectivityManager; +import android.net.wifi.WifiManager; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.IBinder; +import android.preference.PreferenceManager; +import android.util.Rational; +import android.view.Display; +import android.view.Gravity; +import android.view.InputDevice; +import android.view.KeyCharacterMap; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.Surface; +import android.view.SurfaceHolder; +import android.view.View; +import android.view.View.OnGenericMotionListener; +import android.view.View.OnSystemUiVisibilityChangeListener; +import android.view.View.OnTouchListener; +import android.view.ViewGroup; +import android.view.ViewOutlineProvider; +import android.view.ViewParent; +import android.view.Window; +import android.view.WindowManager; +import android.widget.FrameLayout; +import android.view.inputmethod.InputMethodManager; +import android.widget.TextView; +import android.widget.Toast; + +import java.io.ByteArrayInputStream; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + + +public class Game extends Activity implements SurfaceHolder.Callback, + OnGenericMotionListener, OnTouchListener, NvConnectionListener, EvdevListener, + OnSystemUiVisibilityChangeListener, GameGestures, StreamView.InputCallbacks, + PerfOverlayListener, UsbDriverService.UsbDriverStateListener, View.OnKeyListener{ + public static Game instance; + + private int lastButtonState = 0; + + // Only 2 touches are supported + private final TouchContext[] touchContextMap = new TouchContext[2]; + private long threeFingerDownTime = 0; + + private static final int REFERENCE_HORIZ_RES = 1280; + private static final int REFERENCE_VERT_RES = 720; + + private static final int STYLUS_DOWN_DEAD_ZONE_DELAY = 100; + private static final int STYLUS_DOWN_DEAD_ZONE_RADIUS = 20; + + private static final int STYLUS_UP_DEAD_ZONE_DELAY = 150; + private static final int STYLUS_UP_DEAD_ZONE_RADIUS = 50; + + private static final int THREE_FINGER_TAP_THRESHOLD = 300; + + private ControllerHandler controllerHandler; + private KeyboardTranslator keyboardTranslator; + private VirtualController virtualController; + + private KeyBoardController keyBoardController; + + private KeyBoardLayoutController keyBoardLayoutController; + + private PreferenceConfiguration prefConfig; + private SharedPreferences tombstonePrefs; + + private NvConnection conn; + private SpinnerDialog spinner; + private boolean displayedFailureDialog = false; + private boolean connecting = false; + public boolean connected = false; + private boolean autoEnterPip = false; + private boolean surfaceCreated = false; + private boolean attemptedConnection = false; + private int suppressPipRefCount = 0; + private String pcName; + private String appName; + private NvApp app; + private float desiredRefreshRate; + + private InputCaptureProvider inputCaptureProvider; + private int modifierFlags = 0; + private boolean grabbedInput = true; + private boolean cursorVisible = false; + private boolean waitingForAllModifiersUp = false; + private int specialKeyCode = KeyEvent.KEYCODE_UNKNOWN; + private StreamView streamView; + private long lastAbsTouchUpTime = 0; + private long lastAbsTouchDownTime = 0; + private float lastAbsTouchUpX, lastAbsTouchUpY; + private float lastAbsTouchDownX, lastAbsTouchDownY; + + private boolean isHidingOverlays; + private TextView notificationOverlayView; + private int requestedNotificationOverlayVisibility = View.GONE; + private View performanceOverlayView; + + private TextView performanceOverlayLite; + + private TextView performanceOverlayBig; + + private MediaCodecDecoderRenderer decoderRenderer; + private boolean reportedCrash; + + private WifiManager.WifiLock highPerfWifiLock; + private WifiManager.WifiLock lowLatencyWifiLock; + + private boolean connectedToUsbDriverService = false; + private ServiceConnection usbDriverServiceConnection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName componentName, IBinder iBinder) { + UsbDriverService.UsbDriverBinder binder = (UsbDriverService.UsbDriverBinder) iBinder; + binder.setListener(controllerHandler); + binder.setStateListener(Game.this); + binder.start(); + connectedToUsbDriverService = true; + } + + @Override + public void onServiceDisconnected(ComponentName componentName) { + connectedToUsbDriverService = false; + } + }; + + public static final String EXTRA_HOST = "Host"; + public static final String EXTRA_PORT = "Port"; + public static final String EXTRA_HTTPS_PORT = "HttpsPort"; + public static final String EXTRA_APP_NAME = "AppName"; + public static final String EXTRA_APP_ID = "AppId"; + public static final String EXTRA_UNIQUEID = "UniqueId"; + public static final String EXTRA_PC_UUID = "UUID"; + public static final String EXTRA_PC_NAME = "PcName"; + public static final String EXTRA_APP_HDR = "HDR"; + public static final String EXTRA_SERVER_CERT = "ServerCert"; + + private ViewParent rootView; + + @SuppressLint("MissingInflatedId") + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + instance=this; + + UiHelper.setLocale(this); + + // We don't want a title bar + requestWindowFeature(Window.FEATURE_NO_TITLE); + + // Full-screen + getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + + // If we're going to use immersive mode, we want to have + // the entire screen + getWindow().getDecorView().setSystemUiVisibility( + View.SYSTEM_UI_FLAG_LAYOUT_STABLE | + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN); + + getWindow().addFlags(WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN); + + // Listen for UI visibility events + getWindow().getDecorView().setOnSystemUiVisibilityChangeListener(this); + + // Change volume button behavior + setVolumeControlStream(AudioManager.STREAM_MUSIC); + + // Inflate the content + setContentView(R.layout.activity_game); + + // Start the spinner + spinner = SpinnerDialog.displayDialog(this, getResources().getString(R.string.conn_establishing_title), + getResources().getString(R.string.conn_establishing_msg), true); + + // Read the stream preferences + prefConfig = PreferenceConfiguration.readPreferences(this); + tombstonePrefs = Game.this.getSharedPreferences("DecoderTombstone", 0); + + // Enter landscape unless we're on a square screen + setPreferredOrientationForCurrentDisplay(); + + if (prefConfig.stretchVideo || shouldIgnoreInsetsForResolution(prefConfig.width, prefConfig.height)) { + // Allow the activity to layout under notches if the fill-screen option + // was turned on by the user or it's a full-screen native resolution + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + getWindow().getAttributes().layoutInDisplayCutoutMode = + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS; + } + else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + getWindow().getAttributes().layoutInDisplayCutoutMode = + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; + } + } + // Listen for non-touch events on the game surface + streamView = findViewById(R.id.surfaceView); + streamView.setOnGenericMotionListener(this); + streamView.setOnKeyListener(this); + streamView.setInputCallbacks(this); + + //光标是否显示 + cursorVisible=prefConfig.enableMouseLocalCursor; + + //串流画面 顶部居中显示 + if(prefConfig.enableDisplayTopCenter){ + FrameLayout.LayoutParams params= (FrameLayout.LayoutParams) streamView.getLayoutParams(); + params.gravity= Gravity.CENTER_HORIZONTAL|Gravity.TOP; + } + // Listen for touch events on the background touch view to enable trackpad mode + // to work on areas outside of the StreamView itself. We use a separate View + // for this rather than just handling it at the Activity level, because that + // allows proper touch splitting, which the OSC relies upon. + View backgroundTouchView = findViewById(R.id.backgroundTouchView); + backgroundTouchView.setOnTouchListener(this); + + rootView=streamView.getParent(); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + // Request unbuffered input event dispatching for all input classes we handle here. + // Without this, input events are buffered to be delivered in lock-step with VBlank, + // artificially increasing input latency while streaming. + streamView.requestUnbufferedDispatch( + InputDevice.SOURCE_CLASS_BUTTON | // Keyboards + InputDevice.SOURCE_CLASS_JOYSTICK | // Gamepads + InputDevice.SOURCE_CLASS_POINTER | // Touchscreens and mice (w/o pointer capture) + InputDevice.SOURCE_CLASS_POSITION | // Touchpads + InputDevice.SOURCE_CLASS_TRACKBALL // Mice (pointer capture) + ); + backgroundTouchView.requestUnbufferedDispatch( + InputDevice.SOURCE_CLASS_BUTTON | // Keyboards + InputDevice.SOURCE_CLASS_JOYSTICK | // Gamepads + InputDevice.SOURCE_CLASS_POINTER | // Touchscreens and mice (w/o pointer capture) + InputDevice.SOURCE_CLASS_POSITION | // Touchpads + InputDevice.SOURCE_CLASS_TRACKBALL // Mice (pointer capture) + ); + } + + notificationOverlayView = findViewById(R.id.notificationOverlay); + + performanceOverlayView = findViewById(R.id.performanceOverlay); + + performanceOverlayLite = findViewById(R.id.performanceOverlayLite); + + performanceOverlayBig = findViewById(R.id.performanceOverlayBig); + + inputCaptureProvider = InputCaptureManager.getInputCaptureProvider(this, this); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + streamView.setOnCapturedPointerListener(new View.OnCapturedPointerListener() { + @Override + public boolean onCapturedPointer(View view, MotionEvent motionEvent) { +// LimeLog.info("onCapturedPointer="+motionEvent.toString()); +// LimeLog.info("onCapturedPointer-Device="+motionEvent.getDevice().toString()); + return handleMotionEvent(view, motionEvent); + } + }); + } + + // Warn the user if they're on a metered connection + ConnectivityManager connMgr = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE); + if (connMgr.isActiveNetworkMetered()) { + displayTransientMessage(getResources().getString(R.string.conn_metered)); + } + + // Make sure Wi-Fi is fully powered up + WifiManager wifiMgr = (WifiManager) getApplicationContext().getSystemService(Context.WIFI_SERVICE); + try { + highPerfWifiLock = wifiMgr.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "Moonlight High Perf Lock"); + highPerfWifiLock.setReferenceCounted(false); + highPerfWifiLock.acquire(); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + lowLatencyWifiLock = wifiMgr.createWifiLock(WifiManager.WIFI_MODE_FULL_LOW_LATENCY, "Moonlight Low Latency Lock"); + lowLatencyWifiLock.setReferenceCounted(false); + lowLatencyWifiLock.acquire(); + } + } catch (SecurityException e) { + // Some Samsung Galaxy S10+/S10e devices throw a SecurityException from + // WifiLock.acquire() even though we have android.permission.WAKE_LOCK in our manifest. + e.printStackTrace(); + } + + appName = Game.this.getIntent().getStringExtra(EXTRA_APP_NAME); + pcName = Game.this.getIntent().getStringExtra(EXTRA_PC_NAME); + + String host = Game.this.getIntent().getStringExtra(EXTRA_HOST); + int port = Game.this.getIntent().getIntExtra(EXTRA_PORT, NvHTTP.DEFAULT_HTTP_PORT); + int httpsPort = Game.this.getIntent().getIntExtra(EXTRA_HTTPS_PORT, 0); // 0 is treated as unknown + int appId = Game.this.getIntent().getIntExtra(EXTRA_APP_ID, StreamConfiguration.INVALID_APP_ID); + String uniqueId = Game.this.getIntent().getStringExtra(EXTRA_UNIQUEID); + boolean appSupportsHdr = Game.this.getIntent().getBooleanExtra(EXTRA_APP_HDR, false); + byte[] derCertData = Game.this.getIntent().getByteArrayExtra(EXTRA_SERVER_CERT); + + app = new NvApp(appName != null ? appName : "app", appId, appSupportsHdr); + + X509Certificate serverCert = null; + try { + if (derCertData != null) { + serverCert = (X509Certificate) CertificateFactory.getInstance("X.509") + .generateCertificate(new ByteArrayInputStream(derCertData)); + } + } catch (CertificateException e) { + e.printStackTrace(); + } + + if (appId == StreamConfiguration.INVALID_APP_ID) { + finish(); + return; + } + + // Initialize the MediaCodec helper before creating the decoder + GlPreferences glPrefs = GlPreferences.readPreferences(this); + MediaCodecHelper.initialize(this, glPrefs.glRenderer); + + // Check if the user has enabled HDR + boolean willStreamHdr = false; + if (prefConfig.enableHdr) { + // Start our HDR checklist + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + Display display = getWindowManager().getDefaultDisplay(); + Display.HdrCapabilities hdrCaps = display.getHdrCapabilities(); + + // We must now ensure our display is compatible with HDR10 + if (hdrCaps != null) { + // getHdrCapabilities() returns null on Lenovo Lenovo Mirage Solo (vega), Android 8.0 + for (int hdrType : hdrCaps.getSupportedHdrTypes()) { + if (hdrType == Display.HdrCapabilities.HDR_TYPE_HDR10) { + willStreamHdr = true; + break; + } + } + } + + if (!willStreamHdr) { + // Nope, no HDR for us :( + Toast.makeText(this, "Display does not support HDR10", Toast.LENGTH_LONG).show(); + } + } + else { + Toast.makeText(this, "HDR requires Android 7.0 or later", Toast.LENGTH_LONG).show(); + } + } + + // Check if the user has enabled performance stats overlay + if (prefConfig.enablePerfOverlay) { + performanceOverlayView.setVisibility(View.VISIBLE); + if(prefConfig.enablePerfOverlayLite){ + performanceOverlayLite.setVisibility(View.VISIBLE); + if(prefConfig.enablePerfOverlayLiteDialog){ + performanceOverlayLite.setOnClickListener(v -> showGameMenu(null)); + } + }else{ + performanceOverlayBig.setVisibility(View.VISIBLE); + } + } + + decoderRenderer = new MediaCodecDecoderRenderer( + this, + prefConfig, + new CrashListener() { + @Override + public void notifyCrash(Exception e) { + // The MediaCodec instance is going down due to a crash + // let's tell the user something when they open the app again + + // We must use commit because the app will crash when we return from this function + tombstonePrefs.edit().putInt("CrashCount", tombstonePrefs.getInt("CrashCount", 0) + 1).commit(); + reportedCrash = true; + } + }, + tombstonePrefs.getInt("CrashCount", 0), + connMgr.isActiveNetworkMetered(), + willStreamHdr, + glPrefs.glRenderer, + this); + + // Don't stream HDR if the decoder can't support it + if (willStreamHdr && !decoderRenderer.isHevcMain10Hdr10Supported() && !decoderRenderer.isAv1Main10Supported()) { + willStreamHdr = false; + Toast.makeText(this, "Decoder does not support HDR10 profile", Toast.LENGTH_LONG).show(); + } + + // Display a message to the user if HEVC was forced on but we still didn't find a decoder + if (prefConfig.videoFormat == PreferenceConfiguration.FormatOption.FORCE_HEVC && !decoderRenderer.isHevcSupported()) { + Toast.makeText(this, "No HEVC decoder found", Toast.LENGTH_LONG).show(); + } + + // Display a message to the user if AV1 was forced on but we still didn't find a decoder + if (prefConfig.videoFormat == PreferenceConfiguration.FormatOption.FORCE_AV1 && !decoderRenderer.isAv1Supported()) { + Toast.makeText(this, "No AV1 decoder found", Toast.LENGTH_LONG).show(); + } + + // H.264 is always supported + int supportedVideoFormats = MoonBridge.VIDEO_FORMAT_H264; + if (decoderRenderer.isHevcSupported()) { + supportedVideoFormats |= MoonBridge.VIDEO_FORMAT_H265; + if (willStreamHdr && decoderRenderer.isHevcMain10Hdr10Supported()) { + supportedVideoFormats |= MoonBridge.VIDEO_FORMAT_H265_MAIN10; + } + } + if (decoderRenderer.isAv1Supported()) { + supportedVideoFormats |= MoonBridge.VIDEO_FORMAT_AV1_MAIN8; + if (willStreamHdr && decoderRenderer.isAv1Main10Supported()) { + supportedVideoFormats |= MoonBridge.VIDEO_FORMAT_AV1_MAIN10; + } + } + + int gamepadMask = ControllerHandler.getAttachedControllerMask(this); + if (!prefConfig.multiController) { + // Always set gamepad 1 present for when multi-controller is + // disabled for games that don't properly support detection + // of gamepads removed and replugged at runtime. + gamepadMask = 1; + } + if (prefConfig.onscreenController) { + // If we're using OSC, always set at least gamepad 1. + gamepadMask |= 1; + } + + // Set to the optimal mode for streaming + float displayRefreshRate = prepareDisplayForRendering(); + LimeLog.info("Display refresh rate: "+displayRefreshRate); + + // If the user requested frame pacing using a capped FPS, we will need to change our + // desired FPS setting here in accordance with the active display refresh rate. + int roundedRefreshRate = Math.round(displayRefreshRate); + int chosenFrameRate = prefConfig.fps; + if (prefConfig.framePacing == PreferenceConfiguration.FRAME_PACING_CAP_FPS) { + if (prefConfig.fps >= roundedRefreshRate) { + if (prefConfig.fps > roundedRefreshRate + 3) { + // Use frame drops when rendering above the screen frame rate + prefConfig.framePacing = PreferenceConfiguration.FRAME_PACING_BALANCED; + LimeLog.info("Using drop mode for FPS > Hz"); + } else if (roundedRefreshRate <= 49) { + // Let's avoid clearly bogus refresh rates and fall back to legacy rendering + prefConfig.framePacing = PreferenceConfiguration.FRAME_PACING_BALANCED; + LimeLog.info("Bogus refresh rate: " + roundedRefreshRate); + } + else { + chosenFrameRate = roundedRefreshRate - 1; + LimeLog.info("Adjusting FPS target for screen to " + chosenFrameRate); + } + } + } + + StreamConfiguration config = new StreamConfiguration.Builder() + .setResolution(prefConfig.width, prefConfig.height) + .setLaunchRefreshRate(prefConfig.fps) + .setRefreshRate(chosenFrameRate) + .setApp(app) + .setBitrate(prefConfig.bitrate) + .setEnableSops(prefConfig.enableSops) + .enableLocalAudioPlayback(prefConfig.playHostAudio) + .setMaxPacketSize(1392) + .setRemoteConfiguration(StreamConfiguration.STREAM_CFG_AUTO) // NvConnection will perform LAN and VPN detection + .setSupportedVideoFormats(supportedVideoFormats) + .setAttachedGamepadMask(gamepadMask) + .setClientRefreshRateX100((int)(displayRefreshRate * 100)) + .setAudioConfiguration(prefConfig.audioConfiguration) + .setColorSpace(decoderRenderer.getPreferredColorSpace()) + .setColorRange(decoderRenderer.getPreferredColorRange()) + .setPersistGamepadsAfterDisconnect(!prefConfig.multiController) + .build(); + + // Initialize the connection + conn = new NvConnection(getApplicationContext(), + new ComputerDetails.AddressTuple(host, port), + httpsPort, uniqueId, config, + PlatformBinding.getCryptoProvider(this), serverCert); + controllerHandler = new ControllerHandler(this, conn, this, prefConfig); + keyboardTranslator = new KeyboardTranslator(); + + InputManager inputManager = (InputManager) getSystemService(Context.INPUT_SERVICE); + inputManager.registerInputDeviceListener(keyboardTranslator, null); + + // Initialize touch contexts +// for (int i = 0; i < touchContextMap.length; i++) { +// if (!prefConfig.touchscreenTrackpad) { +// touchContextMap[i] = new AbsoluteTouchContext(conn, i, streamView); +// } +// else { +// touchContextMap[i] = new RelativeTouchContext(conn, i, +// REFERENCE_HORIZ_RES, REFERENCE_VERT_RES, +// streamView, prefConfig); +// } +// } + //鼠标触控模式 + String mouseModel=PreferenceManager.getDefaultSharedPreferences(this).getString("mouse_model_list_axi", "0"); + switchMouseModel(Integer.parseInt(mouseModel)); + + if (prefConfig.onscreenController) { + // create virtual onscreen controller + initVirtualController(); + } + + //特殊按键屏幕布局 + if(prefConfig.enableKeyboard){ + initKeyboardController(); + } + + if (prefConfig.usbDriver) { + // Start the USB driver + bindService(new Intent(this, UsbDriverService.class), + usbDriverServiceConnection, Service.BIND_AUTO_CREATE); + } + + if (!decoderRenderer.isAvcSupported()) { + if (spinner != null) { + spinner.dismiss(); + spinner = null; + } + + // If we can't find an AVC decoder, we can't proceed + Dialog.displayDialog(this, getResources().getString(R.string.conn_error_title), + "This device or ROM doesn't support hardware accelerated H.264 playback.", true); + return; + } + + // The connection will be started when the surface gets created + streamView.getHolder().addCallback(this); + + //外接显示器模式 + if(prefConfig.enableExDisplay){ + showSecondScreen(); + } + + } + + private void initKeyboardController(){ + keyBoardController=new KeyBoardController(controllerHandler,(FrameLayout)rootView, this); + keyBoardController.refreshLayout(); + keyBoardController.show(); + } + + + private void initVirtualController(){ + virtualController = new VirtualController(controllerHandler, (FrameLayout)rootView, this); + virtualController.refreshLayout(); + virtualController.show(); + } + + private void initkeyBoardLayoutController(){ + keyBoardLayoutController=new KeyBoardLayoutController(controllerHandler,(FrameLayout)rootView, this); + keyBoardLayoutController.refreshLayout(); + keyBoardLayoutController.show(); + } + + //显示隐藏虚拟特殊按键 + public void showHideKeyboardController(){ + if(keyBoardController==null){ + initKeyboardController(); + return; + } + keyBoardController.switchShowHide(); + } + + public void showHidekeyBoardLayoutController(){ + if(keyBoardLayoutController==null){ + initkeyBoardLayoutController(); + return; + } + keyBoardLayoutController.switchShowHide(); + } + + //显示隐藏虚拟手柄控制器 + public void showHideVirtualController(){ + if(virtualController==null){ + initVirtualController(); + prefConfig.onscreenController=true; + return; + } + prefConfig.onscreenController= virtualController.switchShowHide() != 0; + } + + private void setPreferredOrientationForCurrentDisplay() { + Display display = getWindowManager().getDefaultDisplay(); + + // For semi-square displays, we use more complex logic to determine which orientation to use (if any) + if (PreferenceConfiguration.isSquarishScreen(display)) { + int desiredOrientation = Configuration.ORIENTATION_UNDEFINED; + + // OSC doesn't properly support portrait displays, so don't use it in portrait mode by default + if (prefConfig.onscreenController) { + desiredOrientation = Configuration.ORIENTATION_LANDSCAPE; + } + + // For native resolution, we will lock the orientation to the one that matches the specified resolution + if (PreferenceConfiguration.isNativeResolution(prefConfig.width, prefConfig.height)) { + if (prefConfig.width > prefConfig.height) { + desiredOrientation = Configuration.ORIENTATION_LANDSCAPE; + } + else { + desiredOrientation = Configuration.ORIENTATION_PORTRAIT; + } + } + + if (desiredOrientation == Configuration.ORIENTATION_LANDSCAPE) { + setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE); + } + else if (desiredOrientation == Configuration.ORIENTATION_PORTRAIT) { + setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT); + } + else { + // If we don't have a reason to lock to portrait or landscape, allow any orientation + setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_FULL_USER); + } + } + else { + //强制竖屏模式 + if(prefConfig.enablePortrait){ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { + setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT); + } + else { + setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT); + } + return; + } + + // For regular displays, we always request landscape + setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE); + } + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + + // Set requested orientation for possible new screen size + setPreferredOrientationForCurrentDisplay(); + + if (virtualController != null) { + // Refresh layout of OSC for possible new screen size + virtualController.refreshLayout(); + } + + if(keyBoardController !=null){ + keyBoardController.refreshLayout(); + } + + if(keyBoardLayoutController!=null){ + keyBoardLayoutController.refreshLayout(); + } + + // Hide on-screen overlays in PiP mode + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (isInPictureInPictureMode()) { + isHidingOverlays = true; + + if (virtualController != null) { + virtualController.hide(); + } + + if (keyBoardController != null) { + keyBoardController.hide(); + } + + if(keyBoardLayoutController!=null){ + keyBoardLayoutController.hide(); + } + + performanceOverlayView.setVisibility(View.GONE); + notificationOverlayView.setVisibility(View.GONE); + + // Disable sensors while in PiP mode + controllerHandler.disableSensors(); + + // Update GameManager state to indicate we're in PiP (still gaming, but interruptible) + UiHelper.notifyStreamEnteringPiP(this); + } + else { + isHidingOverlays = false; + + // Restore overlays to previous state when leaving PiP + + if (virtualController != null) { + virtualController.show(); + } + + if (keyBoardController != null) { + keyBoardController.show(); + } + + if(keyBoardLayoutController!=null){ + keyBoardLayoutController.show(); + } + + if (prefConfig.enablePerfOverlay) { + performanceOverlayView.setVisibility(View.VISIBLE); + } + + notificationOverlayView.setVisibility(requestedNotificationOverlayVisibility); + + // Enable sensors again after exiting PiP + controllerHandler.enableSensors(); + + // Update GameManager state to indicate we're out of PiP (gaming, non-interruptible) + UiHelper.notifyStreamExitingPiP(this); + } + } + } + + @TargetApi(Build.VERSION_CODES.O) + private PictureInPictureParams getPictureInPictureParams(boolean autoEnter) { + PictureInPictureParams.Builder builder = + new PictureInPictureParams.Builder() + .setAspectRatio(new Rational(prefConfig.width, prefConfig.height)) + .setSourceRectHint(new Rect( + streamView.getLeft(), streamView.getTop(), + streamView.getRight(), streamView.getBottom())); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + builder.setAutoEnterEnabled(autoEnter); + builder.setSeamlessResizeEnabled(true); + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (appName != null) { + builder.setTitle(appName); + if (pcName != null) { + builder.setSubtitle(pcName); + } + } + else if (pcName != null) { + builder.setTitle(pcName); + } + } + + return builder.build(); + } + + private void updatePipAutoEnter() { + if (!prefConfig.enablePip) { + return; + } + + boolean autoEnter = connected && suppressPipRefCount == 0; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + setPictureInPictureParams(getPictureInPictureParams(autoEnter)); + } + else { + autoEnterPip = autoEnter; + } + } + + public void setMetaKeyCaptureState(boolean enabled) { + // This uses custom APIs present on some Samsung devices to allow capture of + // meta key events while streaming. + try { + Class semWindowManager = Class.forName("com.samsung.android.view.SemWindowManager"); + Method getInstanceMethod = semWindowManager.getMethod("getInstance"); + Object manager = getInstanceMethod.invoke(null); + + if (manager != null) { + Class[] parameterTypes = new Class[2]; + parameterTypes[0] = ComponentName.class; + parameterTypes[1] = boolean.class; + Method requestMetaKeyEventMethod = semWindowManager.getDeclaredMethod("requestMetaKeyEvent", parameterTypes); + requestMetaKeyEventMethod.invoke(manager, this.getComponentName(), enabled); + } + else { + LimeLog.warning("SemWindowManager.getInstance() returned null"); + } + } catch (ClassNotFoundException e) { + e.printStackTrace(); + } catch (NoSuchMethodException e) { + e.printStackTrace(); + } catch (InvocationTargetException e) { + e.printStackTrace(); + } catch (IllegalAccessException e) { + e.printStackTrace(); + } + } + + @Override + public void onUserLeaveHint() { + super.onUserLeaveHint(); + + // PiP is only supported on Oreo and later, and we don't need to manually enter PiP on + // Android S and later. On Android R, we will use onPictureInPictureRequested() instead. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + if (autoEnterPip) { + try { + // This has thrown all sorts of weird exceptions on Samsung devices + // running Oreo. Just eat them and close gracefully on leave, rather + // than crashing. + enterPictureInPictureMode(getPictureInPictureParams(false)); + } catch (Exception e) { + e.printStackTrace(); + } + } + } + } + + @Override + @TargetApi(Build.VERSION_CODES.R) + public boolean onPictureInPictureRequested() { + // Enter PiP when requested unless we're on Android 12 which supports auto-enter. + if (autoEnterPip && Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + enterPictureInPictureMode(getPictureInPictureParams(false)); + } + return true; + } + + @Override + public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + + // We can't guarantee the state of modifiers keys which may have + // lifted while focus was not on us. Clear the modifier state. + this.modifierFlags = 0; + + // With Android native pointer capture, capture is lost when focus is lost, + // so it must be requested again when focus is regained. + inputCaptureProvider.onWindowFocusChanged(hasFocus); + } + + private boolean isRefreshRateEqualMatch(float refreshRate) { + return refreshRate >= prefConfig.fps && + refreshRate <= prefConfig.fps + 3; + } + + private boolean isRefreshRateGoodMatch(float refreshRate) { + return refreshRate >= prefConfig.fps && + Math.round(refreshRate) % prefConfig.fps <= 3; + } + + private boolean shouldIgnoreInsetsForResolution(int width, int height) { + // Never ignore insets for non-native resolutions + if (!PreferenceConfiguration.isNativeResolution(width, height)) { + return false; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + Display display = getWindowManager().getDefaultDisplay(); + for (Display.Mode candidate : display.getSupportedModes()) { + // Ignore insets if this is an exact match for the display resolution + if ((width == candidate.getPhysicalWidth() && height == candidate.getPhysicalHeight()) || + (height == candidate.getPhysicalWidth() && width == candidate.getPhysicalHeight())) { + return true; + } + } + } + + return false; + } + + private boolean mayReduceRefreshRate() { + return prefConfig.framePacing == PreferenceConfiguration.FRAME_PACING_CAP_FPS || + prefConfig.framePacing == PreferenceConfiguration.FRAME_PACING_MAX_SMOOTHNESS || + (prefConfig.framePacing == PreferenceConfiguration.FRAME_PACING_BALANCED && prefConfig.reduceRefreshRate); + } + + private float prepareDisplayForRendering() { + Display display = getWindowManager().getDefaultDisplay(); + WindowManager.LayoutParams windowLayoutParams = getWindow().getAttributes(); + float displayRefreshRate; + + // On M, we can explicitly set the optimal display mode + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + Display.Mode bestMode = display.getMode(); + boolean isNativeResolutionStream = PreferenceConfiguration.isNativeResolution(prefConfig.width, prefConfig.height); + boolean refreshRateIsGood = isRefreshRateGoodMatch(bestMode.getRefreshRate()); + boolean refreshRateIsEqual = isRefreshRateEqualMatch(bestMode.getRefreshRate()); + + LimeLog.info("Current display mode: "+bestMode.getPhysicalWidth()+"x"+ + bestMode.getPhysicalHeight()+"x"+bestMode.getRefreshRate()); + + for (Display.Mode candidate : display.getSupportedModes()) { + boolean refreshRateReduced = candidate.getRefreshRate() < bestMode.getRefreshRate(); + boolean resolutionReduced = candidate.getPhysicalWidth() < bestMode.getPhysicalWidth() || + candidate.getPhysicalHeight() < bestMode.getPhysicalHeight(); + boolean resolutionFitsStream = candidate.getPhysicalWidth() >= prefConfig.width && + candidate.getPhysicalHeight() >= prefConfig.height; + + LimeLog.info("Examining display mode: "+candidate.getPhysicalWidth()+"x"+ + candidate.getPhysicalHeight()+"x"+candidate.getRefreshRate()); + + if (candidate.getPhysicalWidth() > 4096 && prefConfig.width <= 4096) { + // Avoid resolutions options above 4K to be safe + continue; + } + + // On non-4K streams, we force the resolution to never change unless it's above + // 60 FPS, which may require a resolution reduction due to HDMI bandwidth limitations, + // or it's a native resolution stream. + if (prefConfig.width < 3840 && prefConfig.fps <= 60 && !isNativeResolutionStream) { + if (display.getMode().getPhysicalWidth() != candidate.getPhysicalWidth() || + display.getMode().getPhysicalHeight() != candidate.getPhysicalHeight()) { + continue; + } + } + + // Make sure the resolution doesn't regress unless if it's over 60 FPS + // where we may need to reduce resolution to achieve the desired refresh rate. + if (resolutionReduced && !(prefConfig.fps > 60 && resolutionFitsStream)) { + continue; + } + + if (mayReduceRefreshRate() && refreshRateIsEqual && !isRefreshRateEqualMatch(candidate.getRefreshRate())) { + // If we had an equal refresh rate and this one is not, skip it. In min latency + // mode, we want to always prefer the highest frame rate even though it may cause + // microstuttering. + continue; + } + else if (refreshRateIsGood) { + // We've already got a good match, so if this one isn't also good, it's not + // worth considering at all. + if (!isRefreshRateGoodMatch(candidate.getRefreshRate())) { + continue; + } + + if (mayReduceRefreshRate()) { + // User asked for the lowest possible refresh rate, so don't raise it if we + // have a good match already + if (candidate.getRefreshRate() > bestMode.getRefreshRate()) { + continue; + } + } + else { + // User asked for the highest possible refresh rate, so don't reduce it if we + // have a good match already + if (refreshRateReduced) { + continue; + } + } + } + else if (!isRefreshRateGoodMatch(candidate.getRefreshRate())) { + // We didn't have a good match and this match isn't good either, so just don't + // reduce the refresh rate. + if (refreshRateReduced) { + continue; + } + } else { + // We didn't have a good match and this match is good. Prefer this refresh rate + // even if it reduces the refresh rate. Lowering the refresh rate can be beneficial + // when streaming a 60 FPS stream on a 90 Hz device. We want to select 60 Hz to + // match the frame rate even if the active display mode is 90 Hz. + } + + bestMode = candidate; + refreshRateIsGood = isRefreshRateGoodMatch(candidate.getRefreshRate()); + refreshRateIsEqual = isRefreshRateEqualMatch(candidate.getRefreshRate()); + } + + LimeLog.info("Best display mode: "+bestMode.getPhysicalWidth()+"x"+ + bestMode.getPhysicalHeight()+"x"+bestMode.getRefreshRate()); + + // Only apply new window layout parameters if we've actually changed the display mode + if (display.getMode().getModeId() != bestMode.getModeId()) { + // If we only changed refresh rate and we're on an OS that supports Surface.setFrameRate() + // use that instead of using preferredDisplayModeId to avoid the possibility of triggering + // bugs that can cause the system to switch from 4K60 to 4K24 on Chromecast 4K. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || + display.getMode().getPhysicalWidth() != bestMode.getPhysicalWidth() || + display.getMode().getPhysicalHeight() != bestMode.getPhysicalHeight()) { + // Apply the display mode change + windowLayoutParams.preferredDisplayModeId = bestMode.getModeId(); + getWindow().setAttributes(windowLayoutParams); + } + else { + LimeLog.info("Using setFrameRate() instead of preferredDisplayModeId due to matching resolution"); + } + } + else { + LimeLog.info("Current display mode is already the best display mode"); + } + + displayRefreshRate = bestMode.getRefreshRate(); + } + // On L, we can at least tell the OS that we want a refresh rate + else { + float bestRefreshRate = display.getRefreshRate(); + for (float candidate : display.getSupportedRefreshRates()) { + LimeLog.info("Examining refresh rate: "+candidate); + + if (candidate > bestRefreshRate) { + // Ensure the frame rate stays around 60 Hz for <= 60 FPS streams + if (prefConfig.fps <= 60) { + if (candidate >= 63) { + continue; + } + } + + bestRefreshRate = candidate; + } + } + + LimeLog.info("Selected refresh rate: "+bestRefreshRate); + windowLayoutParams.preferredRefreshRate = bestRefreshRate; + displayRefreshRate = bestRefreshRate; + + // Apply the refresh rate change + getWindow().setAttributes(windowLayoutParams); + } + + // Until Marshmallow, we can't ask for a 4K display mode, so we'll + // need to hint the OS to provide one. + boolean aspectRatioMatch = false; + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + // We'll calculate whether we need to scale by aspect ratio. If not, we'll use + // setFixedSize so we can handle 4K properly. The only known devices that have + // >= 4K screens have exactly 4K screens, so we'll be able to hit this good path + // on these devices. On Marshmallow, we can start changing to 4K manually but no + // 4K devices run 6.0 at the moment. + Point screenSize = new Point(0, 0); + display.getSize(screenSize); + + double screenAspectRatio = ((double)screenSize.y) / screenSize.x; + double streamAspectRatio = ((double)prefConfig.height) / prefConfig.width; + if (Math.abs(screenAspectRatio - streamAspectRatio) < 0.001) { + LimeLog.info("Stream has compatible aspect ratio with output display"); + aspectRatioMatch = true; + } + } + + if (prefConfig.stretchVideo || aspectRatioMatch) { + // Set the surface to the size of the video + streamView.getHolder().setFixedSize(prefConfig.width, prefConfig.height); + } + else { + // Set the surface to scale based on the aspect ratio of the stream + streamView.setDesiredAspectRatio((double)prefConfig.width / (double)prefConfig.height); + LimeLog.info("surfaceChanged-->"+(double)prefConfig.width / (double)prefConfig.height); + } + + // Set the desired refresh rate that will get passed into setFrameRate() later + desiredRefreshRate = displayRefreshRate; + + if (getPackageManager().hasSystemFeature(PackageManager.FEATURE_TELEVISION) || + getPackageManager().hasSystemFeature(PackageManager.FEATURE_LEANBACK)) { + // TVs may take a few moments to switch refresh rates, and we can probably assume + // it will be eventually activated. + // TODO: Improve this + return displayRefreshRate; + } + else { + // Use the lower of the current refresh rate and the selected refresh rate. + // The preferred refresh rate may not actually be applied (ex: Battery Saver mode). + return Math.min(getWindowManager().getDefaultDisplay().getRefreshRate(), displayRefreshRate); + } + } + + @SuppressLint("InlinedApi") + private final Runnable hideSystemUi = new Runnable() { + @Override + public void run() { + // TODO: Do we want to use WindowInsetsController here on R+ instead of + // SYSTEM_UI_FLAG_IMMERSIVE_STICKY? They seem to do the same thing as of S... + + // In multi-window mode on N+, we need to drop our layout flags or we'll + // be drawing underneath the system UI. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isInMultiWindowMode()) { + Game.this.getWindow().getDecorView().setSystemUiVisibility( + View.SYSTEM_UI_FLAG_LAYOUT_STABLE); + } + else { + // Use immersive mode + Game.this.getWindow().getDecorView().setSystemUiVisibility( + View.SYSTEM_UI_FLAG_LAYOUT_STABLE | + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_FULLSCREEN | + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY); + } + } + }; + + private void hideSystemUi(int delay) { + Handler h = getWindow().getDecorView().getHandler(); + if (h != null) { + h.removeCallbacks(hideSystemUi); + h.postDelayed(hideSystemUi, delay); + } + } + + @Override + @TargetApi(Build.VERSION_CODES.N) + public void onMultiWindowModeChanged(boolean isInMultiWindowMode) { + super.onMultiWindowModeChanged(isInMultiWindowMode); + + // In multi-window, we don't want to use the full-screen layout + // flag. It will cause us to collide with the system UI. + // This function will also be called for PiP so we can cover + // that case here too. + if (isInMultiWindowMode) { + getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + decoderRenderer.notifyVideoBackground(); + } + else { + getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + decoderRenderer.notifyVideoForeground(); + } + + // Correct the system UI visibility flags + hideSystemUi(50); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + + instance = null; + + if(presentation!=null){ + presentation.dismiss(); + } + + if (controllerHandler != null) { + controllerHandler.destroy(); + } + if (keyboardTranslator != null) { + InputManager inputManager = (InputManager) getSystemService(Context.INPUT_SERVICE); + inputManager.unregisterInputDeviceListener(keyboardTranslator); + } + + if (lowLatencyWifiLock != null) { + lowLatencyWifiLock.release(); + } + if (highPerfWifiLock != null) { + highPerfWifiLock.release(); + } + + if (connectedToUsbDriverService) { + // Unbind from the discovery service + unbindService(usbDriverServiceConnection); + } + + // Destroy the capture provider + inputCaptureProvider.destroy(); + } + + @Override + protected void onPause() { + if (isFinishing()) { + // Stop any further input device notifications before we lose focus (and pointer capture) + if (controllerHandler != null) { + controllerHandler.stop(); + } + + // Ungrab input to prevent further input device notifications + setInputGrabState(false); + } + + super.onPause(); + } + + @Override + protected void onStop() { + super.onStop(); + + SpinnerDialog.closeDialogs(this); + Dialog.closeDialogs(); + + if (virtualController != null) { + virtualController.hide(); + } + if (keyBoardController != null) { + keyBoardController.hide(); + } + + if(keyBoardLayoutController!=null){ + keyBoardLayoutController.hide(); + } + + if (conn != null) { + int videoFormat = decoderRenderer.getActiveVideoFormat(); + + displayedFailureDialog = true; + stopConnection(); + + if (prefConfig.enableLatencyToast) { + int averageEndToEndLat = decoderRenderer.getAverageEndToEndLatency(); + int averageDecoderLat = decoderRenderer.getAverageDecoderLatency(); + String message = null; + if (averageEndToEndLat > 0) { + message = getResources().getString(R.string.conn_client_latency)+" "+averageEndToEndLat+" ms"; + if (averageDecoderLat > 0) { + message += " ("+getResources().getString(R.string.conn_client_latency_hw)+" "+averageDecoderLat+" ms)"; + } + } + else if (averageDecoderLat > 0) { + message = getResources().getString(R.string.conn_hardware_latency)+" "+averageDecoderLat+" ms"; + } + + // Add the video codec to the post-stream toast + if (message != null) { + message += " ["; + + if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_H264) != 0) { + message += "H.264"; + } + else if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_H265) != 0) { + message += "HEVC"; + } + else if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_AV1) != 0) { + message += "AV1"; + } + else { + message += "UNKNOWN"; + } + + if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_10BIT) != 0) { + message += " HDR"; + } + + message += "]"; + } + + if (message != null) { + Toast.makeText(this, message, Toast.LENGTH_LONG).show(); + } + } + + // Clear the tombstone count if we terminated normally + if (!reportedCrash && tombstonePrefs.getInt("CrashCount", 0) != 0) { + tombstonePrefs.edit() + .putInt("CrashCount", 0) + .putInt("LastNotifiedCrashCount", 0) + .apply(); + } + } + + finish(); + } + + private void setInputGrabState(boolean grab) { + // Grab/ungrab the mouse cursor + if (grab) { + inputCaptureProvider.enableCapture(); + + // Enabling capture may hide the cursor again, so + // we will need to show it again. + if (cursorVisible) { + inputCaptureProvider.showCursor(); + } + } + else { + inputCaptureProvider.disableCapture(); + } + + // Grab/ungrab system keyboard shortcuts + setMetaKeyCaptureState(grab); + + grabbedInput = grab; + } + + private final Runnable toggleGrab = new Runnable() { + @Override + public void run() { + setInputGrabState(!grabbedInput); + } + }; + + // Returns true if the key stroke was consumed + private boolean handleSpecialKeys(int androidKeyCode, boolean down) { + int modifierMask = 0; + int nonModifierKeyCode = KeyEvent.KEYCODE_UNKNOWN; + + if (androidKeyCode == KeyEvent.KEYCODE_CTRL_LEFT || + androidKeyCode == KeyEvent.KEYCODE_CTRL_RIGHT) { + modifierMask = KeyboardPacket.MODIFIER_CTRL; + } + else if (androidKeyCode == KeyEvent.KEYCODE_SHIFT_LEFT || + androidKeyCode == KeyEvent.KEYCODE_SHIFT_RIGHT) { + modifierMask = KeyboardPacket.MODIFIER_SHIFT; + } + else if (androidKeyCode == KeyEvent.KEYCODE_ALT_LEFT || + androidKeyCode == KeyEvent.KEYCODE_ALT_RIGHT) { + modifierMask = KeyboardPacket.MODIFIER_ALT; + } + else if (androidKeyCode == KeyEvent.KEYCODE_META_LEFT || + androidKeyCode == KeyEvent.KEYCODE_META_RIGHT) { + modifierMask = KeyboardPacket.MODIFIER_META; + } + else { + nonModifierKeyCode = androidKeyCode; + } + + if (down) { + this.modifierFlags |= modifierMask; + } + else { + this.modifierFlags &= ~modifierMask; + } + + // Handle the special combos on the key up + if (waitingForAllModifiersUp || specialKeyCode != KeyEvent.KEYCODE_UNKNOWN) { + if (specialKeyCode == androidKeyCode) { + // If this is a key up for the special key itself, eat that because the host never saw the original key down + return true; + } + else if (modifierFlags != 0) { + // While we're waiting for modifiers to come up, eat all key downs and allow all key ups to pass + return down; + } + else { + // When all modifiers are up, perform the special action + switch (specialKeyCode) { + // Toggle input grab + case KeyEvent.KEYCODE_Z: + Handler h = getWindow().getDecorView().getHandler(); + if (h != null) { + h.postDelayed(toggleGrab, 250); + } + break; + + // Quit + case KeyEvent.KEYCODE_Q: + finish(); + break; + + // Toggle cursor visibility + case KeyEvent.KEYCODE_C: + if (!grabbedInput) { + inputCaptureProvider.enableCapture(); + grabbedInput = true; + } + cursorVisible = !cursorVisible; + if (cursorVisible) { + inputCaptureProvider.showCursor(); + } else { + inputCaptureProvider.hideCursor(); + } + break; + + default: + break; + } + + // Reset special key state + specialKeyCode = KeyEvent.KEYCODE_UNKNOWN; + waitingForAllModifiersUp = false; + } + } + // Check if Ctrl+Alt+Shift is down when a non-modifier key is pressed + else if ((modifierFlags & (KeyboardPacket.MODIFIER_CTRL | KeyboardPacket.MODIFIER_ALT | KeyboardPacket.MODIFIER_SHIFT)) == + (KeyboardPacket.MODIFIER_CTRL | KeyboardPacket.MODIFIER_ALT | KeyboardPacket.MODIFIER_SHIFT) && + (down && nonModifierKeyCode != KeyEvent.KEYCODE_UNKNOWN)) { + switch (androidKeyCode) { + case KeyEvent.KEYCODE_Z: + case KeyEvent.KEYCODE_Q: + case KeyEvent.KEYCODE_C: + // Remember that a special key combo was activated, so we can consume all key + // events until the modifiers come up + specialKeyCode = androidKeyCode; + waitingForAllModifiersUp = true; + return true; + + default: + // This isn't a special combo that we consume on the client side + return false; + } + } + + // Not a special combo + return false; + } + + // We cannot simply use modifierFlags for all key event processing, because + // some IMEs will not generate real key events for pressing Shift. Instead + // they will simply send key events with isShiftPressed() returning true, + // and we will need to send the modifier flag ourselves. + private byte getModifierState(KeyEvent event) { + // Start with the global modifier state to ensure we cover the case + // detailed in https://github.com/moonlight-stream/moonlight-android/issues/840 + byte modifier = getModifierState(); + if (event.isShiftPressed()) { + modifier |= KeyboardPacket.MODIFIER_SHIFT; + } + if (event.isCtrlPressed()) { + modifier |= KeyboardPacket.MODIFIER_CTRL; + } + if (event.isAltPressed()) { + modifier |= KeyboardPacket.MODIFIER_ALT; + } + if (event.isMetaPressed()) { + modifier |= KeyboardPacket.MODIFIER_META; + } + return modifier; + } + + private byte getModifierState() { + return (byte) modifierFlags; + } + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + return handleKeyDown(event) || super.onKeyDown(keyCode, event); + } + + @Override + public boolean handleKeyDown(KeyEvent event) { + // Pass-through virtual navigation keys + if ((event.getFlags() & KeyEvent.FLAG_VIRTUAL_HARD_KEY) != 0) { + return false; + } + + // Handle a synthetic back button event that some Android OS versions + // create as a result of a right-click. This event WILL repeat if + // the right mouse button is held down, so we ignore those. + int eventSource = event.getSource(); + if ((eventSource == InputDevice.SOURCE_MOUSE || + eventSource == InputDevice.SOURCE_MOUSE_RELATIVE) && + event.getKeyCode() == KeyEvent.KEYCODE_BACK) { + + // Send the right mouse button event if mouse back and forward + // are disabled. If they are enabled, handleMotionEvent() will take + // care of this. + if (!prefConfig.mouseNavButtons) { + conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_RIGHT); + } + + // Always return true, otherwise the back press will be propagated + // up to the parent and finish the activity. + return true; + } + + boolean handled = false; + + if (ControllerHandler.isGameControllerDevice(event.getDevice())) { + // Always try the controller handler first, unless it's an alphanumeric keyboard device. + // Otherwise, controller handler will eat keyboard d-pad events. + handled = controllerHandler.handleButtonDown(event); + } + + // Try the keyboard handler if it wasn't handled as a game controller + if (!handled) { + // Let this method take duplicate key down events + if (handleSpecialKeys(event.getKeyCode(), true)) { + return true; + } + + // Pass through keyboard input if we're not grabbing + if (!grabbedInput) { + return false; + } + + // We'll send it as a raw key event if we have a key mapping, otherwise we'll send it + // as UTF-8 text (if it's a printable character). + short translated = keyboardTranslator.translate(event.getKeyCode(), event.getDeviceId()); + if (translated == 0) { + // Make sure it has a valid Unicode representation and it's not a dead character + // (which we don't support). If those are true, we can send it as UTF-8 text. + // + // NB: We need to be sure this happens before the getRepeatCount() check because + // UTF-8 events don't auto-repeat on the host side. + int unicodeChar = event.getUnicodeChar(); + if ((unicodeChar & KeyCharacterMap.COMBINING_ACCENT) == 0 && (unicodeChar & KeyCharacterMap.COMBINING_ACCENT_MASK) != 0) { + conn.sendUtf8Text(""+(char)unicodeChar); + return true; + } + + return false; + } + + // Eat repeat down events + if (event.getRepeatCount() > 0) { + return true; + } + + conn.sendKeyboardInput(translated, KeyboardPacket.KEY_DOWN, getModifierState(event), + keyboardTranslator.hasNormalizedMapping(event.getKeyCode(), event.getDeviceId()) ? 0 : MoonBridge.SS_KBE_FLAG_NON_NORMALIZED); + } + + return true; + } + + @Override + public boolean onKeyUp(int keyCode, KeyEvent event) { + return handleKeyUp(event) || super.onKeyUp(keyCode, event); + } + + @Override + public boolean handleKeyUp(KeyEvent event) { + // Pass-through virtual navigation keys + if ((event.getFlags() & KeyEvent.FLAG_VIRTUAL_HARD_KEY) != 0) { + return false; + } + + // Handle a synthetic back button event that some Android OS versions + // create as a result of a right-click. + int eventSource = event.getSource(); + if ((eventSource == InputDevice.SOURCE_MOUSE || + eventSource == InputDevice.SOURCE_MOUSE_RELATIVE) && + event.getKeyCode() == KeyEvent.KEYCODE_BACK) { + + // Send the right mouse button event if mouse back and forward + // are disabled. If they are enabled, handleMotionEvent() will take + // care of this. + if (!prefConfig.mouseNavButtons) { + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_RIGHT); + } + + // Always return true, otherwise the back press will be propagated + // up to the parent and finish the activity. + return true; + } + + boolean handled = false; + if (ControllerHandler.isGameControllerDevice(event.getDevice())) { + // Always try the controller handler first, unless it's an alphanumeric keyboard device. + // Otherwise, controller handler will eat keyboard d-pad events. + handled = controllerHandler.handleButtonUp(event); + } + + // Try the keyboard handler if it wasn't handled as a game controller + if (!handled) { + if (handleSpecialKeys(event.getKeyCode(), false)) { + return true; + } + + // Pass through keyboard input if we're not grabbing + if (!grabbedInput) { + return false; + } + + short translated = keyboardTranslator.translate(event.getKeyCode(), event.getDeviceId()); + if (translated == 0) { + // If we sent this event as UTF-8 on key down, also report that it was handled + // when we get the key up event for it. + int unicodeChar = event.getUnicodeChar(); + return (unicodeChar & KeyCharacterMap.COMBINING_ACCENT) == 0 && (unicodeChar & KeyCharacterMap.COMBINING_ACCENT_MASK) != 0; + } + + conn.sendKeyboardInput(translated, KeyboardPacket.KEY_UP, getModifierState(event), + keyboardTranslator.hasNormalizedMapping(event.getKeyCode(), event.getDeviceId()) ? 0 : MoonBridge.SS_KBE_FLAG_NON_NORMALIZED); + } + + return true; + } + + @Override + public boolean onKeyMultiple(int keyCode, int repeatCount, KeyEvent event) { + return handleKeyMultiple(event) || super.onKeyMultiple(keyCode, repeatCount, event); + } + + private boolean handleKeyMultiple(KeyEvent event) { + // We can receive keys from a software keyboard that don't correspond to any existing + // KEYCODE value. Android will give those to us as an ACTION_MULTIPLE KeyEvent. + // + // Despite the fact that the Android docs say this is unused since API level 29, these + // events are still sent as of Android 13 for the above case. + // + // For other cases of ACTION_MULTIPLE, we will not report those as handled so hopefully + // they will be passed to us again as regular singular key events. + if (event.getKeyCode() != KeyEvent.KEYCODE_UNKNOWN || event.getCharacters() == null) { + return false; + } + + conn.sendUtf8Text(event.getCharacters()); + return true; + } + + private TouchContext getTouchContext(int actionIndex) + { + if (actionIndex < touchContextMap.length) { + return touchContextMap[actionIndex]; + } + else { + return null; + } + } + + @Override + public void toggleKeyboard() { + LimeLog.info("Toggling keyboard overlay"); + InputMethodManager inputManager = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); + inputManager.toggleSoftInput(0, 0); + } + + private byte getLiTouchTypeFromEvent(MotionEvent event) { + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: + case MotionEvent.ACTION_POINTER_DOWN: + return MoonBridge.LI_TOUCH_EVENT_DOWN; + + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_POINTER_UP: + if ((event.getFlags() & MotionEvent.FLAG_CANCELED) != 0) { + return MoonBridge.LI_TOUCH_EVENT_CANCEL; + } + else { + return MoonBridge.LI_TOUCH_EVENT_UP; + } + + case MotionEvent.ACTION_MOVE: + return MoonBridge.LI_TOUCH_EVENT_MOVE; + + case MotionEvent.ACTION_CANCEL: + // ACTION_CANCEL applies to *all* pointers in the gesture, so it maps to CANCEL_ALL + // rather than CANCEL. For a single pointer cancellation, that's indicated via + // FLAG_CANCELED on a ACTION_POINTER_UP. + // https://developer.android.com/develop/ui/views/touch-and-input/gestures/multi + return MoonBridge.LI_TOUCH_EVENT_CANCEL_ALL; + + case MotionEvent.ACTION_HOVER_ENTER: + case MotionEvent.ACTION_HOVER_MOVE: + return MoonBridge.LI_TOUCH_EVENT_HOVER; + + case MotionEvent.ACTION_HOVER_EXIT: + return MoonBridge.LI_TOUCH_EVENT_HOVER_LEAVE; + + case MotionEvent.ACTION_BUTTON_PRESS: + case MotionEvent.ACTION_BUTTON_RELEASE: + return MoonBridge.LI_TOUCH_EVENT_BUTTON_ONLY; + + default: + return -1; + } + } + + //灵敏度保存到集合 适配多个手指 + private Map sensitivityMap=new HashMap<>(); + + //修改移动的触控灵敏度(通过修改移动的距离实现) 默认使用右半边屏幕的时候开启 + private float[] getStreamViewRelativeSensitivityXY(MotionEvent event,float normalizedX,float normalizedY,int pointerIndex){ + float[] normalized=new float[2]; + normalized[0]=normalizedX; + normalized[1]=normalizedY; + + //如果不是全局模式 并且 坐标 不在右边 则返回 + if(!prefConfig.touchSensitivityGlobal&&normalizedX=streamView.getWidth()){ + normalizedX=streamView.getWidth()/2.0f; + } + if(normalizedY>=streamView.getHeight()){ + normalizedY=streamView.getHeight()/2.0f; + } + } + bean.setLastAbsoluteX(event.getX(pointerIndex)); + bean.setLastAbsoluteY(event.getY(pointerIndex)); + bean.setLastRelativelyX(normalizedX); + bean.setLastRelativelyY(normalizedY); + sensitivityMap.put(String.valueOf(event.getPointerId(pointerIndex)),bean); + } + //抬起的时候,恢复初始化状态 + if (event.getActionMasked() == MotionEvent.ACTION_UP||event.getActionMasked() == MotionEvent.ACTION_POINTER_UP) { + sensitivityMap.remove(String.valueOf(event.getPointerId(pointerIndex))); + } + normalized[0]=normalizedX; + normalized[1]=normalizedY; + return normalized; + } + + + private float[] getStreamViewRelativeNormalizedXY(View view, MotionEvent event, int pointerIndex) { + float normalizedX = event.getX(pointerIndex); + float normalizedY = event.getY(pointerIndex); + //开启自定义修改触控灵敏度 并且 数值不为100 + if(prefConfig.enableTouchSensitivity&&(prefConfig.touchSensitivityX !=100||prefConfig.touchSensitivityY!=100)){ + float[] normalized=getStreamViewRelativeSensitivityXY(event,normalizedX,normalizedY,pointerIndex); + normalizedX=normalized[0]; + normalizedY=normalized[1]; + } + // For the containing background view, we must subtract the origin + // of the StreamView to get video-relative coordinates. + if (view != streamView) { + normalizedX -= streamView.getX(); + normalizedY -= streamView.getY(); + } + + normalizedX = Math.max(normalizedX, 0.0f); + normalizedY = Math.max(normalizedY, 0.0f); + + normalizedX = Math.min(normalizedX, streamView.getWidth()); + normalizedY = Math.min(normalizedY, streamView.getHeight()); + + normalizedX /= streamView.getWidth(); + normalizedY /= streamView.getHeight(); + + return new float[] { normalizedX, normalizedY }; + } + + private static float normalizeValueInRange(float value, InputDevice.MotionRange range) { + return (value - range.getMin()) / range.getRange(); + } + + private static float getPressureOrDistance(MotionEvent event, int pointerIndex) { + InputDevice dev = event.getDevice(); + switch (event.getActionMasked()) { + case MotionEvent.ACTION_HOVER_ENTER: + case MotionEvent.ACTION_HOVER_MOVE: + case MotionEvent.ACTION_HOVER_EXIT: + // Hover events report distance + if (dev != null) { + InputDevice.MotionRange distanceRange = dev.getMotionRange(MotionEvent.AXIS_DISTANCE, event.getSource()); + if (distanceRange != null) { + return normalizeValueInRange(event.getAxisValue(MotionEvent.AXIS_DISTANCE, pointerIndex), distanceRange); + } + } + return 0.0f; + + default: + // Other events report pressure + return event.getPressure(pointerIndex); + } + } + + private static short getRotationDegrees(MotionEvent event, int pointerIndex) { + InputDevice dev = event.getDevice(); + if (dev != null) { + if (dev.getMotionRange(MotionEvent.AXIS_ORIENTATION, event.getSource()) != null) { + short rotationDegrees = (short) Math.toDegrees(event.getOrientation(pointerIndex)); + if (rotationDegrees < 0) { + rotationDegrees += 360; + } + return rotationDegrees; + } + } + return MoonBridge.LI_ROT_UNKNOWN; + } + + private static float[] polarToCartesian(float r, float theta) { + return new float[] { (float)(r * Math.cos(theta)), (float)(r * Math.sin(theta)) }; + } + + private static float cartesianToR(float[] point) { + return (float)Math.sqrt(Math.pow(point[0], 2) + Math.pow(point[1], 2)); + } + + private float[] getStreamViewNormalizedContactArea(MotionEvent event, int pointerIndex) { + float orientation; + + // If the orientation is unknown, we'll just assume it's at a 45 degree angle and scale it by + // X and Y scaling factors evenly. + if (event.getDevice() == null || event.getDevice().getMotionRange(MotionEvent.AXIS_ORIENTATION, event.getSource()) == null) { + orientation = (float)(Math.PI / 4); + } + else { + orientation = event.getOrientation(pointerIndex); + } + + float contactAreaMajor, contactAreaMinor; + switch (event.getActionMasked()) { + // Hover events report the tool size + case MotionEvent.ACTION_HOVER_ENTER: + case MotionEvent.ACTION_HOVER_MOVE: + case MotionEvent.ACTION_HOVER_EXIT: + contactAreaMajor = event.getToolMajor(pointerIndex); + contactAreaMinor = event.getToolMinor(pointerIndex); + break; + + // Other events report contact area + default: + contactAreaMajor = event.getTouchMajor(pointerIndex); + contactAreaMinor = event.getTouchMinor(pointerIndex); + break; + } + + // The contact area major axis is parallel to the orientation, so we simply convert + // polar to cartesian coordinates using the orientation as theta. + float[] contactAreaMajorCartesian = polarToCartesian(contactAreaMajor, orientation); + + // The contact area minor axis is perpendicular to the contact area major axis (and thus + // the orientation), so rotate the orientation angle by 90 degrees. + float[] contactAreaMinorCartesian = polarToCartesian(contactAreaMinor, (float)(orientation + (Math.PI / 2))); + + // Normalize the contact area to the stream view size + contactAreaMajorCartesian[0] = Math.min(Math.abs(contactAreaMajorCartesian[0]), streamView.getWidth()) / streamView.getWidth(); + contactAreaMinorCartesian[0] = Math.min(Math.abs(contactAreaMinorCartesian[0]), streamView.getWidth()) / streamView.getWidth(); + contactAreaMajorCartesian[1] = Math.min(Math.abs(contactAreaMajorCartesian[1]), streamView.getHeight()) / streamView.getHeight(); + contactAreaMinorCartesian[1] = Math.min(Math.abs(contactAreaMinorCartesian[1]), streamView.getHeight()) / streamView.getHeight(); + + // Convert the normalized values back into polar coordinates + return new float[] { cartesianToR(contactAreaMajorCartesian), cartesianToR(contactAreaMinorCartesian) }; + } + + private boolean sendPenEventForPointer(View view, MotionEvent event, byte eventType, byte toolType, int pointerIndex) { + byte penButtons = 0; + if ((event.getButtonState() & MotionEvent.BUTTON_STYLUS_PRIMARY) != 0) { + penButtons |= MoonBridge.LI_PEN_BUTTON_PRIMARY; + } + if ((event.getButtonState() & MotionEvent.BUTTON_STYLUS_SECONDARY) != 0) { + penButtons |= MoonBridge.LI_PEN_BUTTON_SECONDARY; + } + + byte tiltDegrees = MoonBridge.LI_TILT_UNKNOWN; + InputDevice dev = event.getDevice(); + if (dev != null) { + if (dev.getMotionRange(MotionEvent.AXIS_TILT, event.getSource()) != null) { + tiltDegrees = (byte)Math.toDegrees(event.getAxisValue(MotionEvent.AXIS_TILT, pointerIndex)); + } + } + + float[] normalizedCoords = getStreamViewRelativeNormalizedXY(view, event, pointerIndex); + float[] normalizedContactArea = getStreamViewNormalizedContactArea(event, pointerIndex); + return conn.sendPenEvent(eventType, toolType, penButtons, + normalizedCoords[0], normalizedCoords[1], + getPressureOrDistance(event, pointerIndex), + normalizedContactArea[0], normalizedContactArea[1], + getRotationDegrees(event, pointerIndex), tiltDegrees) != MoonBridge.LI_ERR_UNSUPPORTED; + } + + private static byte convertToolTypeToStylusToolType(MotionEvent event, int pointerIndex) { + switch (event.getToolType(pointerIndex)) { + case MotionEvent.TOOL_TYPE_ERASER: + return MoonBridge.LI_TOOL_TYPE_ERASER; + case MotionEvent.TOOL_TYPE_STYLUS: + return MoonBridge.LI_TOOL_TYPE_PEN; + default: + return MoonBridge.LI_TOOL_TYPE_UNKNOWN; + } + } + + private boolean trySendPenEvent(View view, MotionEvent event) { + byte eventType = getLiTouchTypeFromEvent(event); + if (eventType < 0) { + return false; + } + + if (event.getActionMasked() == MotionEvent.ACTION_MOVE) { + // Move events may impact all active pointers + boolean handledStylusEvent = false; + for (int i = 0; i < event.getPointerCount(); i++) { + byte toolType = convertToolTypeToStylusToolType(event, i); + if (toolType == MoonBridge.LI_TOOL_TYPE_UNKNOWN) { + // Not a stylus pointer, so skip it + continue; + } + else { + // This pointer is a stylus, so we'll report that we handled this event + handledStylusEvent = true; + } + + if (!sendPenEventForPointer(view, event, eventType, toolType, i)) { + // Pen events aren't supported by the host + return false; + } + } + return handledStylusEvent; + } + else if (event.getActionMasked() == MotionEvent.ACTION_CANCEL) { + // Cancel impacts all active pointers + return conn.sendPenEvent(MoonBridge.LI_TOUCH_EVENT_CANCEL_ALL, MoonBridge.LI_TOOL_TYPE_UNKNOWN, (byte)0, + 0, 0, 0, 0, 0, + MoonBridge.LI_ROT_UNKNOWN, MoonBridge.LI_TILT_UNKNOWN) != MoonBridge.LI_ERR_UNSUPPORTED; + } + else { + // Up, Down, and Hover events are specific to the action index + byte toolType = convertToolTypeToStylusToolType(event, event.getActionIndex()); + if (toolType == MoonBridge.LI_TOOL_TYPE_UNKNOWN) { + // Not a stylus event + return false; + } + return sendPenEventForPointer(view, event, eventType, toolType, event.getActionIndex()); + } + } + + private boolean sendTouchEventForPointer(View view, MotionEvent event, byte eventType, int pointerIndex) { + float[] normalizedCoords = getStreamViewRelativeNormalizedXY(view, event, pointerIndex); + float[] normalizedContactArea = getStreamViewNormalizedContactArea(event, pointerIndex); + return conn.sendTouchEvent(eventType, event.getPointerId(pointerIndex), + normalizedCoords[0], normalizedCoords[1], + getPressureOrDistance(event, pointerIndex), + normalizedContactArea[0], normalizedContactArea[1], + getRotationDegrees(event, pointerIndex)) != MoonBridge.LI_ERR_UNSUPPORTED; + } + + private boolean trySendTouchEvent(View view, MotionEvent event) { + byte eventType = getLiTouchTypeFromEvent(event); + if (eventType < 0) { + return false; + } + + if (event.getActionMasked() == MotionEvent.ACTION_MOVE) { + // Move events may impact all active pointers + for (int i = 0; i < event.getPointerCount(); i++) { + if (!sendTouchEventForPointer(view, event, eventType, i)) { + return false; + } + } + return true; + } + else if (event.getActionMasked() == MotionEvent.ACTION_CANCEL) { + // Cancel impacts all active pointers + return conn.sendTouchEvent(MoonBridge.LI_TOUCH_EVENT_CANCEL_ALL, 0, + 0, 0, 0, 0, 0, + MoonBridge.LI_ROT_UNKNOWN) != MoonBridge.LI_ERR_UNSUPPORTED; + } + else { + // Up, Down, and Hover events are specific to the action index + return sendTouchEventForPointer(view, event, eventType, event.getActionIndex()); + } + } + + // Returns true if the event was consumed + // NB: View is only present if called from a view callback + private boolean handleMotionEvent(View view, MotionEvent event) { + // Pass through mouse/touch/joystick input if we're not grabbing + if (!grabbedInput) { + return false; + } + + int eventSource = event.getSource(); + int deviceSources = event.getDevice() != null ? event.getDevice().getSources() : 0; + if ((eventSource & InputDevice.SOURCE_CLASS_JOYSTICK) != 0) { + if (controllerHandler.handleMotionEvent(event)) { + return true; + } + } + else if ((deviceSources & InputDevice.SOURCE_CLASS_JOYSTICK) != 0 && controllerHandler.tryHandleTouchpadEvent(event)) { + return true; + } + else if ((eventSource & InputDevice.SOURCE_CLASS_POINTER) != 0 || + (eventSource & InputDevice.SOURCE_CLASS_POSITION) != 0 || + eventSource == InputDevice.SOURCE_MOUSE_RELATIVE) + { + // This case is for mice and non-finger touch devices + if (eventSource == InputDevice.SOURCE_MOUSE || + (eventSource & InputDevice.SOURCE_CLASS_POSITION) != 0 || // SOURCE_TOUCHPAD + eventSource == InputDevice.SOURCE_MOUSE_RELATIVE || + (event.getPointerCount() >= 1 && + (event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE || + event.getToolType(0) == MotionEvent.TOOL_TYPE_STYLUS || + event.getToolType(0) == MotionEvent.TOOL_TYPE_ERASER)) || + eventSource == 12290) // 12290 = Samsung DeX mode desktop mouse + { + int buttonState = event.getButtonState(); + int changedButtons = buttonState ^ lastButtonState; + + // The DeX touchpad on the Fold 4 sends proper right click events using BUTTON_SECONDARY, + // but doesn't send BUTTON_PRIMARY for a regular click. Instead it sends ACTION_DOWN/UP, + // so we need to fix that up to look like a sane input event to process it correctly. + if (eventSource == 12290) { + if (event.getActionMasked() == MotionEvent.ACTION_DOWN) { + buttonState |= MotionEvent.BUTTON_PRIMARY; + } + else if (event.getAction() == MotionEvent.ACTION_UP) { + buttonState &= ~MotionEvent.BUTTON_PRIMARY; + } + else { + // We may be faking the primary button down from a previous event, + // so be sure to add that bit back into the button state. + buttonState |= (lastButtonState & MotionEvent.BUTTON_PRIMARY); + } + + changedButtons = buttonState ^ lastButtonState; + } + + // Ignore mouse input if we're not capturing from our input source + if (!inputCaptureProvider.isCapturingActive()) { + // We return true here because otherwise the events may end up causing + // Android to synthesize d-pad events. + return true; + } + + // Always update the position before sending any button events. If we're + // dealing with a stylus without hover support, our position might be + // significantly different than before. + if (inputCaptureProvider.eventHasRelativeMouseAxes(event)) { + // Send the deltas straight from the motion event + short deltaX = (short)inputCaptureProvider.getRelativeAxisX(event); + short deltaY = (short)inputCaptureProvider.getRelativeAxisY(event); + + if (deltaX != 0 || deltaY != 0) { + if (prefConfig.absoluteMouseMode) { + // NB: view may be null, but we can unconditionally use streamView because we don't need to adjust + // relative axis deltas for the position of the streamView within the parent's coordinate system. + conn.sendMouseMoveAsMousePosition(deltaX, deltaY, (short)streamView.getWidth(), (short)streamView.getHeight()); + } + else { + conn.sendMouseMove(deltaX, deltaY); + } + } + } + else if ((eventSource & InputDevice.SOURCE_CLASS_POSITION) != 0) { + // If this input device is not associated with the view itself (like a trackpad), + // we'll convert the device-specific coordinates to use to send the cursor position. + // This really isn't ideal but it's probably better than nothing. + // + // Trackpad on newer versions of Android (Oreo and later) should be caught by the + // relative axes case above. If we get here, we're on an older version that doesn't + // support pointer capture. + InputDevice device = event.getDevice(); + if (device != null) { + InputDevice.MotionRange xRange = device.getMotionRange(MotionEvent.AXIS_X, eventSource); + InputDevice.MotionRange yRange = device.getMotionRange(MotionEvent.AXIS_Y, eventSource); + + // All touchpads coordinate planes should start at (0, 0) + if (xRange != null && yRange != null && xRange.getMin() == 0 && yRange.getMin() == 0) { + int xMax = (int)xRange.getMax(); + int yMax = (int)yRange.getMax(); + + // Touchpads must be smaller than (65535, 65535) + if (xMax <= Short.MAX_VALUE && yMax <= Short.MAX_VALUE) { + conn.sendMousePosition((short)event.getX(), (short)event.getY(), + (short)xMax, (short)yMax); + } + } + } + } + else if (view != null && trySendPenEvent(view, event)) { + // If our host supports pen events, send it directly + return true; + } + else if (view != null) { + // Otherwise send absolute position based on the view for SOURCE_CLASS_POINTER + updateMousePosition(view, event); + } + + if (event.getActionMasked() == MotionEvent.ACTION_SCROLL) { + // Send the vertical scroll packet + conn.sendMouseHighResScroll((short)(event.getAxisValue(MotionEvent.AXIS_VSCROLL) * 120)); + conn.sendMouseHighResHScroll((short)(event.getAxisValue(MotionEvent.AXIS_HSCROLL) * 120)); + } + + if ((changedButtons & MotionEvent.BUTTON_PRIMARY) != 0) { + if ((buttonState & MotionEvent.BUTTON_PRIMARY) != 0) { + conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_LEFT); + } + else { + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT); + } + } + + // Mouse secondary or stylus primary is right click (stylus down is left click) + if ((changedButtons & (MotionEvent.BUTTON_SECONDARY | MotionEvent.BUTTON_STYLUS_PRIMARY)) != 0) { + if ((buttonState & (MotionEvent.BUTTON_SECONDARY | MotionEvent.BUTTON_STYLUS_PRIMARY)) != 0) { + conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_RIGHT); + } + else { + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_RIGHT); + } + } + + // Mouse tertiary or stylus secondary is middle click + if ((changedButtons & (MotionEvent.BUTTON_TERTIARY | MotionEvent.BUTTON_STYLUS_SECONDARY)) != 0) { + if ((buttonState & (MotionEvent.BUTTON_TERTIARY | MotionEvent.BUTTON_STYLUS_SECONDARY)) != 0) { + conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_MIDDLE); + } + else { + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_MIDDLE); + } + } + + if (prefConfig.mouseNavButtons) { + if ((changedButtons & MotionEvent.BUTTON_BACK) != 0) { + if ((buttonState & MotionEvent.BUTTON_BACK) != 0) { + conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_X1); + } + else { + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_X1); + } + } + + if ((changedButtons & MotionEvent.BUTTON_FORWARD) != 0) { + if ((buttonState & MotionEvent.BUTTON_FORWARD) != 0) { + conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_X2); + } + else { + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_X2); + } + } + } + + // Handle stylus presses + if (event.getPointerCount() == 1 && event.getActionIndex() == 0) { + if (event.getActionMasked() == MotionEvent.ACTION_DOWN) { + if (event.getToolType(0) == MotionEvent.TOOL_TYPE_STYLUS) { + lastAbsTouchDownTime = event.getEventTime(); + lastAbsTouchDownX = event.getX(0); + lastAbsTouchDownY = event.getY(0); + + // Stylus is left click + conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_LEFT); + } else if (event.getToolType(0) == MotionEvent.TOOL_TYPE_ERASER) { + lastAbsTouchDownTime = event.getEventTime(); + lastAbsTouchDownX = event.getX(0); + lastAbsTouchDownY = event.getY(0); + + // Eraser is right click + conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_RIGHT); + } + } + else if (event.getActionMasked() == MotionEvent.ACTION_UP || event.getActionMasked() == MotionEvent.ACTION_CANCEL) { + if (event.getToolType(0) == MotionEvent.TOOL_TYPE_STYLUS) { + lastAbsTouchUpTime = event.getEventTime(); + lastAbsTouchUpX = event.getX(0); + lastAbsTouchUpY = event.getY(0); + + // Stylus is left click + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT); + } else if (event.getToolType(0) == MotionEvent.TOOL_TYPE_ERASER) { + lastAbsTouchUpTime = event.getEventTime(); + lastAbsTouchUpX = event.getX(0); + lastAbsTouchUpY = event.getY(0); + + // Eraser is right click + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_RIGHT); + } + } + } + + lastButtonState = buttonState; + } + // This case is for fingers + else + { + if (virtualController != null && + (virtualController.getControllerMode() == VirtualController.ControllerMode.MoveButtons || + virtualController.getControllerMode() == VirtualController.ControllerMode.ResizeButtons|| + virtualController.getControllerMode() == VirtualController.ControllerMode.DisableEnableButtons)) { + // Ignore presses when the virtual controller is being configured + return true; + } + + if (keyBoardController != null && + (keyBoardController.getControllerMode() == KeyBoardController.ControllerMode.MoveButtons || + keyBoardController.getControllerMode() == KeyBoardController.ControllerMode.ResizeButtons|| + keyBoardController.getControllerMode() == KeyBoardController.ControllerMode.DisableEnableButtons)) { + // Ignore presses when the virtual controller is being configured + return true; + } + //禁用鼠标 + if(disableMouseModel){ + return true; + } + + // If this is the parent view, we'll offset our coordinates to appear as if they + // are relative to the StreamView like our StreamView touch events are. + float xOffset, yOffset; + if (view != streamView && !prefConfig.touchscreenTrackpad) { + xOffset = -streamView.getX(); + yOffset = -streamView.getY(); + } + else { + xOffset = 0.f; + yOffset = 0.f; + } + + // TODO: Re-enable native touch when have a better solution for handling + // cancelled touches from Android gestures and 3 finger taps to activate the software keyboard. + if(prefConfig.enableMultiTouchScreen){ + if (!prefConfig.touchscreenTrackpad && trySendTouchEvent(view, event)) { + // If this host supports touch events and absolute touch is enabled, + // send it directly as a touch event. + return true; + } + } + + int actionIndex = event.getActionIndex(); + + int eventX = (int)(event.getX(actionIndex) + xOffset); + int eventY = (int)(event.getY(actionIndex) + yOffset); + + // Special handling for 3 finger gesture + if (event.getActionMasked() == MotionEvent.ACTION_POINTER_DOWN && + event.getPointerCount() == 3) { + // Three fingers down + threeFingerDownTime = event.getEventTime(); + + // Cancel the first and second touches to avoid + // erroneous events + for (TouchContext aTouchContext : touchContextMap) { + aTouchContext.cancelTouch(); + } + + return true; + } + + TouchContext context = getTouchContext(actionIndex); + if (context == null) { + return false; + } + + switch (event.getActionMasked()) + { + case MotionEvent.ACTION_POINTER_DOWN: + case MotionEvent.ACTION_DOWN: + for (TouchContext touchContext : touchContextMap) { + touchContext.setPointerCount(event.getPointerCount()); + } + context.touchDownEvent(eventX, eventY, event.getEventTime(), true); + break; + case MotionEvent.ACTION_POINTER_UP: + case MotionEvent.ACTION_UP: + //是触控板模式 三点呼出软键盘 + if(prefConfig.touchscreenTrackpad){ + if (event.getPointerCount() == 1 && + (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || (event.getFlags() & MotionEvent.FLAG_CANCELED) == 0)) { + // All fingers up + if (event.getEventTime() - threeFingerDownTime < THREE_FINGER_TAP_THRESHOLD) { + // This is a 3 finger tap to bring up the keyboard + toggleKeyboard(); + return true; + } + } + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && (event.getFlags() & MotionEvent.FLAG_CANCELED) != 0) { + context.cancelTouch(); + } + else { + context.touchUpEvent(eventX, eventY, event.getEventTime()); + } + + for (TouchContext touchContext : touchContextMap) { + touchContext.setPointerCount(event.getPointerCount() - 1); + } + if (actionIndex == 0 && event.getPointerCount() > 1 && !context.isCancelled()) { + // The original secondary touch now becomes primary + context.touchDownEvent( + (int)(event.getX(1) + xOffset), + (int)(event.getY(1) + yOffset), + event.getEventTime(), false); + } + break; + case MotionEvent.ACTION_MOVE: + // ACTION_MOVE is special because it always has actionIndex == 0 + // We'll call the move handlers for all indexes manually + + // First process the historical events + for (int i = 0; i < event.getHistorySize(); i++) { + for (TouchContext aTouchContextMap : touchContextMap) { + if (aTouchContextMap.getActionIndex() < event.getPointerCount()) + { + aTouchContextMap.touchMoveEvent( + (int)(event.getHistoricalX(aTouchContextMap.getActionIndex(), i) + xOffset), + (int)(event.getHistoricalY(aTouchContextMap.getActionIndex(), i) + yOffset), + event.getHistoricalEventTime(i)); + } + } + } + + // Now process the current values + for (TouchContext aTouchContextMap : touchContextMap) { + if (aTouchContextMap.getActionIndex() < event.getPointerCount()) + { + aTouchContextMap.touchMoveEvent( + (int)(event.getX(aTouchContextMap.getActionIndex()) + xOffset), + (int)(event.getY(aTouchContextMap.getActionIndex()) + yOffset), + event.getEventTime()); + } + } + break; + case MotionEvent.ACTION_CANCEL: + for (TouchContext aTouchContext : touchContextMap) { + aTouchContext.cancelTouch(); + aTouchContext.setPointerCount(0); + } + break; + default: + return false; + } + } + + // Handled a known source + return true; + } + + // Unknown class + return false; + } + + @Override + public boolean onGenericMotionEvent(MotionEvent event) { + return handleMotionEvent(null, event) || super.onGenericMotionEvent(event); + + } + + private void updateMousePosition(View touchedView, MotionEvent event) { + // X and Y are already relative to the provided view object + float eventX, eventY; + + // For our StreamView itself, we can use the coordinates unmodified. + if (touchedView == streamView) { + eventX = event.getX(0); + eventY = event.getY(0); + } + else { + // For the containing background view, we must subtract the origin + // of the StreamView to get video-relative coordinates. + eventX = event.getX(0) - streamView.getX(); + eventY = event.getY(0) - streamView.getY(); + } + + if (event.getPointerCount() == 1 && event.getActionIndex() == 0 && + (event.getToolType(0) == MotionEvent.TOOL_TYPE_ERASER || + event.getToolType(0) == MotionEvent.TOOL_TYPE_STYLUS)) + { + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: + case MotionEvent.ACTION_HOVER_ENTER: + case MotionEvent.ACTION_HOVER_EXIT: + case MotionEvent.ACTION_HOVER_MOVE: + if (event.getEventTime() - lastAbsTouchUpTime <= STYLUS_UP_DEAD_ZONE_DELAY && + Math.sqrt(Math.pow(eventX - lastAbsTouchUpX, 2) + Math.pow(eventY - lastAbsTouchUpY, 2)) <= STYLUS_UP_DEAD_ZONE_RADIUS) { + // Enforce a small deadzone between touch up and hover or touch down to allow more precise double-clicking + return; + } + break; + + case MotionEvent.ACTION_MOVE: + case MotionEvent.ACTION_UP: + if (event.getEventTime() - lastAbsTouchDownTime <= STYLUS_DOWN_DEAD_ZONE_DELAY && + Math.sqrt(Math.pow(eventX - lastAbsTouchDownX, 2) + Math.pow(eventY - lastAbsTouchDownY, 2)) <= STYLUS_DOWN_DEAD_ZONE_RADIUS) { + // Enforce a small deadzone between touch down and move or touch up to allow more precise double-clicking + return; + } + break; + } + } + + // We may get values slightly outside our view region on ACTION_HOVER_ENTER and ACTION_HOVER_EXIT. + // Normalize these to the view size. We can't just drop them because we won't always get an event + // right at the boundary of the view, so dropping them would result in our cursor never really + // reaching the sides of the screen. + eventX = Math.min(Math.max(eventX, 0), streamView.getWidth()); + eventY = Math.min(Math.max(eventY, 0), streamView.getHeight()); + + conn.sendMousePosition((short)eventX, (short)eventY, (short)streamView.getWidth(), (short)streamView.getHeight()); + } + + @Override + public boolean onGenericMotion(View view, MotionEvent event) { + return handleMotionEvent(view, event); + } + + @SuppressLint("ClickableViewAccessibility") + @Override + public boolean onTouch(View view, MotionEvent event) { + if (event.getAction() == MotionEvent.ACTION_DOWN) { + // Tell the OS not to buffer input events for us + // + // NB: This is still needed even when we call the newer requestUnbufferedDispatch()! + view.requestUnbufferedDispatch(event); + } + + return handleMotionEvent(view, event); + } + + @Override + public void stageStarting(final String stage) { + runOnUiThread(new Runnable() { + @Override + public void run() { + if (spinner != null) { + spinner.setMessage(getResources().getString(R.string.conn_starting) + " " + stage); + } + } + }); + } + + @Override + public void stageComplete(String stage) { + } + + private void stopConnection() { + if (connecting || connected) { + connecting = connected = false; + updatePipAutoEnter(); + + controllerHandler.stop(); + + // Update GameManager state to indicate we're no longer in game + UiHelper.notifyStreamEnded(this); + + // Stop may take a few hundred ms to do some network I/O to tell + // the server we're going away and clean up. Let it run in a separate + // thread to keep things smooth for the UI. Inside moonlight-common, + // we prevent another thread from starting a connection before and + // during the process of stopping this one. + new Thread() { + public void run() { + conn.stop(); + } + }.start(); + } + } + + @Override + public void stageFailed(final String stage, final int portFlags, final int errorCode) { + // Perform a connection test if the failure could be due to a blocked port + // This does network I/O, so don't do it on the main thread. + final int portTestResult = MoonBridge.testClientConnectivity(ServerHelper.CONNECTION_TEST_SERVER, 443, portFlags); + + runOnUiThread(new Runnable() { + @Override + public void run() { + if (spinner != null) { + spinner.dismiss(); + spinner = null; + } + + if (!displayedFailureDialog) { + displayedFailureDialog = true; + LimeLog.severe(stage + " failed: " + errorCode); + + // If video initialization failed and the surface is still valid, display extra information for the user + if (stage.contains("video") && streamView.getHolder().getSurface().isValid()) { + Toast.makeText(Game.this, getResources().getText(R.string.video_decoder_init_failed), Toast.LENGTH_LONG).show(); + } + + String dialogText = getResources().getString(R.string.conn_error_msg) + " " + stage +" (error "+errorCode+")"; + + if (portFlags != 0) { + dialogText += "\n\n" + getResources().getString(R.string.check_ports_msg) + "\n" + + MoonBridge.stringifyPortFlags(portFlags, "\n"); + } + + if (portTestResult != MoonBridge.ML_TEST_RESULT_INCONCLUSIVE && portTestResult != 0) { + dialogText += "\n\n" + getResources().getString(R.string.nettest_text_blocked); + } + + Dialog.displayDialog(Game.this, getResources().getString(R.string.conn_error_title), dialogText, true); + } + } + }); + } + + @Override + public void connectionTerminated(final int errorCode) { + // Perform a connection test if the failure could be due to a blocked port + // This does network I/O, so don't do it on the main thread. + final int portFlags = MoonBridge.getPortFlagsFromTerminationErrorCode(errorCode); + final int portTestResult = MoonBridge.testClientConnectivity(ServerHelper.CONNECTION_TEST_SERVER,443, portFlags); + + runOnUiThread(new Runnable() { + @Override + public void run() { + // Let the display go to sleep now + getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + + // Stop processing controller input + controllerHandler.stop(); + + // Ungrab input + setInputGrabState(false); + + if (!displayedFailureDialog) { + displayedFailureDialog = true; + LimeLog.severe("Connection terminated: " + errorCode); + stopConnection(); + + // Display the error dialog if it was an unexpected termination. + // Otherwise, just finish the activity immediately. + if (errorCode != MoonBridge.ML_ERROR_GRACEFUL_TERMINATION) { + String message; + + if (portTestResult != MoonBridge.ML_TEST_RESULT_INCONCLUSIVE && portTestResult != 0) { + // If we got a blocked result, that supersedes any other error message + message = getResources().getString(R.string.nettest_text_blocked); + } + else { + switch (errorCode) { + case MoonBridge.ML_ERROR_NO_VIDEO_TRAFFIC: + message = getResources().getString(R.string.no_video_received_error); + break; + + case MoonBridge.ML_ERROR_NO_VIDEO_FRAME: + message = getResources().getString(R.string.no_frame_received_error); + break; + + case MoonBridge.ML_ERROR_UNEXPECTED_EARLY_TERMINATION: + case MoonBridge.ML_ERROR_PROTECTED_CONTENT: + message = getResources().getString(R.string.early_termination_error); + break; + + case MoonBridge.ML_ERROR_FRAME_CONVERSION: + message = getResources().getString(R.string.frame_conversion_error); + break; + + default: + String errorCodeString; + // We'll assume large errors are hex values + if (Math.abs(errorCode) > 1000) { + errorCodeString = Integer.toHexString(errorCode); + } + else { + errorCodeString = Integer.toString(errorCode); + } + message = getResources().getString(R.string.conn_terminated_msg) + "\n\n" + + getResources().getString(R.string.error_code_prefix) + " " + errorCodeString; + break; + } + } + + if (portFlags != 0) { + message += "\n\n" + getResources().getString(R.string.check_ports_msg) + "\n" + + MoonBridge.stringifyPortFlags(portFlags, "\n"); + } + + Dialog.displayDialog(Game.this, getResources().getString(R.string.conn_terminated_title), + message, true); + } + else { + finish(); + } + } + } + }); + } + + @Override + public void connectionStatusUpdate(final int connectionStatus) { + runOnUiThread(new Runnable() { + @Override + public void run() { + if (prefConfig.disableWarnings) { + return; + } + + if (connectionStatus == MoonBridge.CONN_STATUS_POOR) { + if (prefConfig.bitrate > 5000) { + notificationOverlayView.setText(getResources().getString(R.string.slow_connection_msg)); + } + else { + notificationOverlayView.setText(getResources().getString(R.string.poor_connection_msg)); + } + + requestedNotificationOverlayVisibility = View.VISIBLE; + } + else if (connectionStatus == MoonBridge.CONN_STATUS_OKAY) { + requestedNotificationOverlayVisibility = View.GONE; + } + + if (!isHidingOverlays) { + notificationOverlayView.setVisibility(requestedNotificationOverlayVisibility); + } + } + }); + } + + @Override + public void connectionStarted() { + runOnUiThread(new Runnable() { + @Override + public void run() { + if (spinner != null) { + spinner.dismiss(); + spinner = null; + } + + connected = true; + connecting = false; + updatePipAutoEnter(); + + // Hide the mouse cursor now after a short delay. + // Doing it before dismissing the spinner seems to be undone + // when the spinner gets displayed. On Android Q, even now + // is too early to capture. We will delay a second to allow + // the spinner to dismiss before capturing. + Handler h = new Handler(); + h.postDelayed(new Runnable() { + @Override + public void run() { + setInputGrabState(true); + } + }, 500); + + // Keep the display on + getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + + // Update GameManager state to indicate we're in game + UiHelper.notifyStreamConnected(Game.this); + + hideSystemUi(1000); + } + }); + + // Report this shortcut being used (off the main thread to prevent ANRs) + ComputerDetails computer = new ComputerDetails(); + computer.name = pcName; + computer.uuid = Game.this.getIntent().getStringExtra(EXTRA_PC_UUID); + ShortcutHelper shortcutHelper = new ShortcutHelper(this); + shortcutHelper.reportComputerShortcutUsed(computer); + if (appName != null) { + // This may be null if launched from the "Resume Session" PC context menu item + shortcutHelper.reportGameLaunched(computer, app); + } + } + + @Override + public void displayMessage(final String message) { + runOnUiThread(new Runnable() { + @Override + public void run() { + Toast.makeText(Game.this, message, Toast.LENGTH_LONG).show(); + } + }); + } + + @Override + public void displayTransientMessage(final String message) { + if (!prefConfig.disableWarnings) { + runOnUiThread(new Runnable() { + @Override + public void run() { + Toast.makeText(Game.this, message, Toast.LENGTH_LONG).show(); + } + }); + } + } + + @Override + public void rumble(short controllerNumber, short lowFreqMotor, short highFreqMotor) { + LimeLog.info(String.format((Locale)null, "Rumble on gamepad %d: %04x %04x", controllerNumber, lowFreqMotor, highFreqMotor)); + + controllerHandler.handleRumble(controllerNumber, lowFreqMotor, highFreqMotor); + } + + @Override + public void rumbleTriggers(short controllerNumber, short leftTrigger, short rightTrigger) { + LimeLog.info(String.format((Locale)null, "Rumble on gamepad triggers %d: %04x %04x", controllerNumber, leftTrigger, rightTrigger)); + + controllerHandler.handleRumbleTriggers(controllerNumber, leftTrigger, rightTrigger); + } + + @Override + public void setHdrMode(boolean enabled, byte[] hdrMetadata) { + LimeLog.info("Display HDR mode: " + (enabled ? "enabled" : "disabled")); + decoderRenderer.setHdrMode(enabled, hdrMetadata); + } + + @Override + public void setMotionEventState(short controllerNumber, byte motionType, short reportRateHz) { + controllerHandler.handleSetMotionEventState(controllerNumber, motionType, reportRateHz); + } + + @Override + public void setControllerLED(short controllerNumber, byte r, byte g, byte b) { + controllerHandler.handleSetControllerLED(controllerNumber, r, g, b); + } + + @Override + public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { + if (!surfaceCreated) { + throw new IllegalStateException("Surface changed before creation!"); + } + + LimeLog.info("surfaceChanged-->"+width+" x "+height + "----"+prefConfig.width+" x "+prefConfig.height); + if (!attemptedConnection) { + attemptedConnection = true; + + // Update GameManager state to indicate we're "loading" while connecting + UiHelper.notifyStreamConnecting(Game.this); + + decoderRenderer.setRenderTarget(holder.getSurface()); + conn.start(new AndroidAudioRenderer(Game.this, prefConfig.enableAudioFx), + decoderRenderer, Game.this); + } + } + + @Override + public void surfaceCreated(SurfaceHolder holder) { + float desiredFrameRate; + + surfaceCreated = true; + + // Android will pick the lowest matching refresh rate for a given frame rate value, so we want + // to report the true FPS value if refresh rate reduction is enabled. We also report the true + // FPS value if there's no suitable matching refresh rate. In that case, Android could try to + // select a lower refresh rate that avoids uneven pull-down (ex: 30 Hz for a 60 FPS stream on + // a display that maxes out at 50 Hz). + if (mayReduceRefreshRate() || desiredRefreshRate < prefConfig.fps) { + desiredFrameRate = prefConfig.fps; + } + else { + // Otherwise, we will pretend that our frame rate matches the refresh rate we picked in + // prepareDisplayForRendering(). This will usually be the highest refresh rate that our + // frame rate evenly divides into, which ensures the lowest possible display latency. + desiredFrameRate = desiredRefreshRate; + } + + // Tell the OS about our frame rate to allow it to adapt the display refresh rate appropriately + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + // We want to change frame rate even if it's not seamless, since prepareDisplayForRendering() + // will not set the display mode on S+ if it only differs by the refresh rate. It depends + // on us to trigger the frame rate switch here. + holder.getSurface().setFrameRate(desiredFrameRate, + Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE, + Surface.CHANGE_FRAME_RATE_ALWAYS); + } + else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + holder.getSurface().setFrameRate(desiredFrameRate, + Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE); + } + } + + @Override + public void surfaceDestroyed(SurfaceHolder holder) { + if (!surfaceCreated) { + throw new IllegalStateException("Surface destroyed before creation!"); + } + + if (attemptedConnection) { + // Let the decoder know immediately that the surface is gone + decoderRenderer.prepareForStop(); + + if (connected) { + stopConnection(); + } + } + } + + @Override + public void mouseMove(int deltaX, int deltaY) { + conn.sendMouseMove((short) deltaX, (short) deltaY); + } + + @Override + public void mouseButtonEvent(int buttonId, boolean down) { + byte buttonIndex; + + switch (buttonId) + { + case EvdevListener.BUTTON_LEFT: + buttonIndex = MouseButtonPacket.BUTTON_LEFT; + break; + case EvdevListener.BUTTON_MIDDLE: + buttonIndex = MouseButtonPacket.BUTTON_MIDDLE; + break; + case EvdevListener.BUTTON_RIGHT: + buttonIndex = MouseButtonPacket.BUTTON_RIGHT; + break; + case EvdevListener.BUTTON_X1: + buttonIndex = MouseButtonPacket.BUTTON_X1; + break; + case EvdevListener.BUTTON_X2: + buttonIndex = MouseButtonPacket.BUTTON_X2; + break; + default: + LimeLog.warning("Unhandled button: "+buttonId); + return; + } + + if (down) { + conn.sendMouseButtonDown(buttonIndex); + } + else { + conn.sendMouseButtonUp(buttonIndex); + } + } + + @Override + public void mouseVScroll(byte amount) { + conn.sendMouseScroll(amount); + } + + @Override + public void mouseHScroll(byte amount) { + conn.sendMouseHScroll(amount); + } + + @Override + public void keyboardEvent(boolean buttonDown, short keyCode) { + short keyMap = keyboardTranslator.translate(keyCode, -1); + if (keyMap != 0) { + // handleSpecialKeys() takes the Android keycode + if (handleSpecialKeys(keyCode, buttonDown)) { + return; + } + + if (buttonDown) { + conn.sendKeyboardInput(keyMap, KeyboardPacket.KEY_DOWN, getModifierState(), (byte)0); + } + else { + conn.sendKeyboardInput(keyMap, KeyboardPacket.KEY_UP, getModifierState(), (byte)0); + } + } + } + + @Override + public void onSystemUiVisibilityChange(int visibility) { + // Don't do anything if we're not connected + if (!connected) { + return; + } + + // This flag is set for all devices + if ((visibility & View.SYSTEM_UI_FLAG_FULLSCREEN) == 0) { + hideSystemUi(2000); + } + else if ((visibility & View.SYSTEM_UI_FLAG_HIDE_NAVIGATION) == 0) { + hideSystemUi(2000); + } + } + + @Override + public void onPerfUpdate(final String text) { + runOnUiThread(new Runnable() { + @Override + public void run() { + if(prefConfig.enablePerfOverlayLite){ + performanceOverlayLite.setText(text); + }else{ + performanceOverlayBig.setText(text); + } + } + }); + } + + @Override + public void onUsbPermissionPromptStarting() { + // Disable PiP auto-enter while the USB permission prompt is on-screen. This prevents + // us from entering PiP while the user is interacting with the OS permission dialog. + suppressPipRefCount++; + updatePipAutoEnter(); + } + + @Override + public void onUsbPermissionPromptCompleted() { + suppressPipRefCount--; + updatePipAutoEnter(); + } + + @Override + public boolean onKey(View view, int keyCode, KeyEvent keyEvent) { + switch (keyEvent.getAction()) { + case KeyEvent.ACTION_DOWN: + return handleKeyDown(keyEvent); + case KeyEvent.ACTION_UP: + return handleKeyUp(keyEvent); + case KeyEvent.ACTION_MULTIPLE: + return handleKeyMultiple(keyEvent); + default: + return false; + } + } + + @Override + public void onBackPressed() { + if(prefConfig.enableQtDialog){ + showGameMenu(null); + return; + } + super.onBackPressed(); + } + + //禁用鼠标 + private boolean disableMouseModel; + + public void switchMouseModel(){ + String[] strings=getResources().getStringArray(R.array.mouse_model_names_axi); + String[] items =Arrays.copyOf(strings,strings.length+1); + items[items.length-1]="切换本地鼠标(需外接物理鼠标)"; +// {"多点触控模式","普通鼠标模式","触控板模式","禁用鼠标/触控","普通鼠标模式(左右键互换)","切换本地鼠标(需外接物理鼠标)"} + new AlertDialog.Builder(this).setItems(items, (dialog, which) -> { + dialog.dismiss(); + //切换本地鼠标 + if(which==5){ + switchMouseLocalCursor(); + return; + } + switchMouseModel(which); + }).setTitle("请选择鼠标模式").create().show(); + } + + //本地鼠标光标切换 + private void switchMouseLocalCursor(){ + if (!grabbedInput) { + inputCaptureProvider.enableCapture(); + grabbedInput = true; + } + cursorVisible = !cursorVisible; + if (cursorVisible) { + inputCaptureProvider.showCursor(); + } else { + inputCaptureProvider.hideCursor(); + } + } + + private void switchMouseModel(int which){ + disableMouseModel=false; + //多点触控 + if(which==0){ + prefConfig.enableMultiTouchScreen=true; + prefConfig.touchscreenTrackpad=false; + } + //普通鼠标模式 + if(which==1){ + prefConfig.enableMultiTouchScreen=false; + prefConfig.touchscreenTrackpad=false; + } + //触控板模式 + if(which==2){ + prefConfig.enableMultiTouchScreen=false; + prefConfig.touchscreenTrackpad=true; + } + //禁用鼠标 + if(which==3){ + disableMouseModel=true; + return; + } + //普通鼠标 左右键互换 + if(which==4){ + prefConfig.enableMultiTouchScreen=false; + prefConfig.touchscreenTrackpad=false; + } + for (int i = 0; i < touchContextMap.length; i++) { + if (!prefConfig.touchscreenTrackpad) { + if(which==4){ + touchContextMap[i] = new AbsoluteTouchSwitchContext(conn, i, streamView); + }else{ + touchContextMap[i] = new AbsoluteTouchContext(conn, i, streamView); + } + } + else { + touchContextMap[i] = new RelativeTouchContext(conn, i, + REFERENCE_HORIZ_RES, REFERENCE_VERT_RES, + streamView, prefConfig); + } + } + } + + public void showHUD(){ + prefConfig.enablePerfOverlay=!prefConfig.enablePerfOverlay; + if(prefConfig.enablePerfOverlay){ + performanceOverlayView.setVisibility(View.VISIBLE); + if(prefConfig.enablePerfOverlayLite){ + performanceOverlayLite.setVisibility(View.VISIBLE); + }else{ + performanceOverlayBig.setVisibility(View.VISIBLE); + } + return; + } + performanceOverlayView.setVisibility(View.GONE); + } + + //切换触控灵敏度开关 + public void switchTouchSensitivity(){ + prefConfig.enableTouchSensitivity=!prefConfig.enableTouchSensitivity; + } + + + public void disconnect() { + finish(); + } + + @Override + public void showGameMenu(GameInputDevice device) { + new GameMenu(this,conn,device); + } + + + private SecondaryDisplayPresentation presentation; + public void showSecondScreen(){ + DisplayManager displayManager = (DisplayManager) getSystemService(Context.DISPLAY_SERVICE); + Display[] displays = displayManager.getDisplays(); + int mainDisplayId = Display.DEFAULT_DISPLAY; + int secondaryDisplayId = -1; + for (Display display : displays) { +// LimeLog.info(display.toString()); + if (display.getDisplayId() != mainDisplayId) { + secondaryDisplayId = display.getDisplayId(); + break; + } + } + if (secondaryDisplayId != -1) { + Display secondaryDisplay = displayManager.getDisplay(secondaryDisplayId); + presentation = new SecondaryDisplayPresentation(this, secondaryDisplay); + presentation.show(); + if(rootView!= null) { + ((ViewGroup)rootView).removeView(streamView); // <- fix + presentation.addView(streamView); + } + + } + } + + + // 设置surfaceView的圆角 setSurfaceviewCorner(UiHelper.dpToPx(this,24)); + private void setSurfaceviewCorner(final float radius) { + + streamView.setOutlineProvider(new ViewOutlineProvider() { + @Override + public void getOutline(View view, Outline outline) { + Rect rect = new Rect(); + view.getGlobalVisibleRect(rect); + int leftMargin = 0; + int topMargin = 0; + Rect selfRect = new Rect(leftMargin, topMargin, rect.right - rect.left - leftMargin, rect.bottom - rect.top - topMargin); + outline.setRoundRect(selfRect, radius); + } + }); + streamView.setClipToOutline(true); + } +} diff --git a/app/src/main/java/com/limelight/GameMenu.java b/app/src/main/java/com/limelight/GameMenu.java new file mode 100755 index 0000000000..a7bb716df7 --- /dev/null +++ b/app/src/main/java/com/limelight/GameMenu.java @@ -0,0 +1,294 @@ +package com.limelight; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.SharedPreferences; +import android.os.Handler; +import android.preference.PreferenceManager; +import android.text.TextUtils; +import android.view.KeyEvent; +import android.widget.ArrayAdapter; +import android.widget.Toast; + +import com.limelight.binding.input.GameInputDevice; +import com.limelight.binding.input.KeyboardTranslator; +import com.limelight.nvstream.NvConnection; +import com.limelight.nvstream.input.KeyboardPacket; +import com.limelight.preferences.PreferenceConfiguration; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.List; + +/** + * Provide options for ongoing Game Stream. + *

+ * Shown on back action in game activity. + */ +public class GameMenu { + + private static final long TEST_GAME_FOCUS_DELAY = 10; + private static final long KEY_UP_DELAY = 25; + + public static final String PREF_NAME = "specialPrefs"; // SharedPreferences的名称 + + public static final String KEY_NAME = "special_key"; // 要保存的键名称 + + public static class MenuOption { + private final String label; + private final boolean withGameFocus; + private final Runnable runnable; + + public MenuOption(String label, boolean withGameFocus, Runnable runnable) { + this.label = label; + this.withGameFocus = withGameFocus; + this.runnable = runnable; + } + + public MenuOption(String label, Runnable runnable) { + this(label, false, runnable); + } + } + + private final Game game; + private final NvConnection conn; + private final GameInputDevice device; + + public GameMenu(Game game, NvConnection conn, GameInputDevice device) { + this.game = game; + this.conn = conn; + this.device = device; + + showMenu(); + } + + private String getString(int id) { + return game.getResources().getString(id); + } + + private static byte getModifier(short key) { + switch (key) { + case KeyboardTranslator.VK_LSHIFT: + return KeyboardPacket.MODIFIER_SHIFT; + case KeyboardTranslator.VK_LCONTROL: + return KeyboardPacket.MODIFIER_CTRL; + case KeyboardTranslator.VK_LWIN: + return KeyboardPacket.MODIFIER_META; + case KeyboardTranslator.VK_LMENU: + return KeyboardPacket.MODIFIER_ALT; + default: + return 0; + } + } + + private void sendKeys(short[] keys) { + final byte[] modifier = {(byte) 0}; + + for (short key : keys) { + conn.sendKeyboardInput(key, KeyboardPacket.KEY_DOWN, modifier[0], (byte) 0); + + // Apply the modifier of the pressed key, e.g. CTRL first issues a CTRL event (without + // modifier) and then sends the following keys with the CTRL modifier applied + modifier[0] |= getModifier(key); + } + + new Handler().postDelayed((() -> { + + for (int pos = keys.length - 1; pos >= 0; pos--) { + short key = keys[pos]; + + // Remove the keys modifier before releasing the key + modifier[0] &= ~getModifier(key); + + conn.sendKeyboardInput(key, KeyboardPacket.KEY_UP, modifier[0], (byte) 0); + } + }), KEY_UP_DELAY); + } + + private void runWithGameFocus(Runnable runnable) { + // Ensure that the Game activity is still active (not finished) + if (game.isFinishing()) { + return; + } + // Check if the game window has focus again, if not try again after delay + if (!game.hasWindowFocus()) { + new Handler().postDelayed(() -> runWithGameFocus(runnable), TEST_GAME_FOCUS_DELAY); + return; + } + // Game Activity has focus, run runnable + runnable.run(); + } + + private void run(MenuOption option) { + if (option.runnable == null) { + return; + } + + if (option.withGameFocus) { + runWithGameFocus(option.runnable); + } else { + option.runnable.run(); + } + } + + private void showMenuDialog(String title, MenuOption[] options) { + AlertDialog.Builder builder = new AlertDialog.Builder(game); + builder.setTitle(title); + + final ArrayAdapter actions = + new ArrayAdapter(game, android.R.layout.simple_list_item_1); + + for (MenuOption option : options) { + actions.add(option.label); + } + + builder.setAdapter(actions, (dialog, which) -> { + String label = actions.getItem(which); + for (MenuOption option : options) { + if (!label.equals(option.label)) { + continue; + } + + run(option); + break; + } + }); + + builder.show(); + } + + private void showSpecialKeysMenu() { + List options = new ArrayList<>(); + + if(!PreferenceConfiguration.readPreferences(game).enableClearDefaultSpecial){ + options.add(new MenuOption(getString(R.string.game_menu_send_keys_esc), + () -> sendKeys(new short[]{KeyboardTranslator.VK_ESCAPE}))); + + options.add(new MenuOption(getString(R.string.game_menu_send_keys_f11), + () -> sendKeys(new short[]{KeyboardTranslator.VK_F11}))); + + options.add(new MenuOption(getString(R.string.game_menu_send_keys_alt_f4), + () -> sendKeys(new short[]{KeyboardTranslator.VK_LMENU, KeyboardTranslator.VK_F4}))); + + options.add(new MenuOption(getString(R.string.game_menu_send_keys_alt_enter), + () -> sendKeys(new short[]{KeyboardTranslator.VK_LMENU, KeyboardTranslator.VK_RETURN}))); + + options.add(new MenuOption(getString(R.string.game_menu_send_keys_ctrl_v), + () -> sendKeys(new short[]{KeyboardTranslator.VK_LCONTROL, KeyboardTranslator.VK_V}))); + + options.add(new MenuOption(getString(R.string.game_menu_send_keys_win), + () -> sendKeys(new short[]{KeyboardTranslator.VK_LWIN}))); + + options.add(new MenuOption(getString(R.string.game_menu_send_keys_win_d), + () -> sendKeys(new short[]{KeyboardTranslator.VK_LWIN, KeyboardTranslator.VK_D}))); + + options.add(new MenuOption(getString(R.string.game_menu_send_keys_win_g), + () -> sendKeys(new short[]{KeyboardTranslator.VK_LWIN, KeyboardTranslator.VK_G}))); + + options.add(new MenuOption(getString(R.string.game_menu_send_keys_shift_tab), + () -> sendKeys(new short[]{KeyboardTranslator.VK_LSHIFT, KeyboardTranslator.VK_TAB}))); + + options.add(new MenuOption(getString(R.string.game_menu_send_keys_win_shift_left), + () -> sendKeys(new short[]{KeyboardTranslator.VK_LWIN, KeyboardTranslator.VK_LSHIFT, KeyboardTranslator.VK_LEFT}))); + + options.add(new MenuOption(getString(R.string.game_menu_send_keys_ctrl_alt_shift_q), + () -> sendKeys(new short[]{KeyboardTranslator.VK_LCONTROL,KeyboardTranslator.VK_LMENU, KeyboardTranslator.VK_LSHIFT, KeyboardTranslator.VK_Q}))); + + options.add(new MenuOption(getString(R.string.game_menu_send_keys_ctrl_alt_shift_f1), + () -> sendKeys(new short[]{KeyboardTranslator.VK_LCONTROL,KeyboardTranslator.VK_LMENU, KeyboardTranslator.VK_LSHIFT, KeyboardTranslator.VK_F1}))); + + options.add(new MenuOption(getString(R.string.game_menu_send_keys_ctrl_alt_shift_f12), + () -> sendKeys(new short[]{KeyboardTranslator.VK_LCONTROL,KeyboardTranslator.VK_LMENU, KeyboardTranslator.VK_LSHIFT, KeyboardTranslator.VK_F12}))); + + options.add(new MenuOption(getString(R.string.game_menu_send_keys_alt_b), + () -> sendKeys(new short[]{KeyboardTranslator.VK_LWIN, KeyboardTranslator.VK_LMENU, KeyboardTranslator.VK_B}))); + options.add(new MenuOption(getString(R.string.game_menu_send_keys_win_x_u_s), () -> { + sendKeys(new short[]{KeyboardTranslator.VK_LWIN, KeyboardTranslator.VK_X}); + new Handler().postDelayed((() -> sendKeys(new short[]{KeyboardTranslator.VK_U, KeyboardTranslator.VK_S})), 200); + })); + options.add(new MenuOption(getString(R.string.game_menu_send_keys_win_x_u_u), () -> { + sendKeys(new short[]{KeyboardTranslator.VK_LWIN, KeyboardTranslator.VK_X}); + new Handler().postDelayed((() -> sendKeys(new short[]{KeyboardTranslator.VK_U, KeyboardTranslator.VK_U})), 200); + })); + options.add(new MenuOption(getString(R.string.game_menu_send_keys_win_x_u_r), () -> { + sendKeys(new short[]{KeyboardTranslator.VK_LWIN, KeyboardTranslator.VK_X}); + new Handler().postDelayed((() -> sendKeys(new short[]{KeyboardTranslator.VK_U, KeyboardTranslator.VK_R})), 200); + })); + options.add(new MenuOption(getString(R.string.game_menu_send_keys_win_x_u_i), () -> { + sendKeys(new short[]{KeyboardTranslator.VK_LWIN, KeyboardTranslator.VK_X}); + new Handler().postDelayed((() -> sendKeys(new short[]{KeyboardTranslator.VK_U, KeyboardTranslator.VK_I})), 200); + })); + + } + + //自定义导入的指令 + SharedPreferences preferences=game.getSharedPreferences(PREF_NAME, Activity.MODE_PRIVATE); + String value=preferences.getString(KEY_NAME,""); + + if(!TextUtils.isEmpty(value)){ + try { + JSONObject object=new JSONObject(value); + JSONArray array=object.optJSONArray("data"); + if(array!=null&&array.length()>0){ + for (int i = 0; i < array.length(); i++) { + JSONObject object1=array.getJSONObject(i); + String name=object1.optString("name"); + JSONArray array1=object1.getJSONArray("data"); + short[] datas=new short[array1.length()]; + for (int j = 0; j < array1.length(); j++) { + String code=array1.getString(j); + datas[j]= (short) Integer.parseInt(code.substring(2), 16); + } + MenuOption option=new MenuOption(name, () -> sendKeys(datas)); + options.add(option); + } + } + } catch (Exception e) { + e.printStackTrace(); + Toast.makeText(game,"自定义导入格式出错了,请检查!",Toast.LENGTH_SHORT).show(); + } + } + options.add(new MenuOption(getString(R.string.game_menu_cancel), null)); + + showMenuDialog(getString(R.string.game_menu_send_keys), options.toArray(new MenuOption[options.size()])); + } + + private void showMenu() { + List options = new ArrayList<>(); + + options.add(new MenuOption(getString(R.string.game_menu_disconnect), () -> game.disconnect())); + + options.add(new MenuOption(getString(R.string.game_menu_toggle_keyboard), true, + () -> game.toggleKeyboard())); + + options.add(new MenuOption(getString(R.string.game_menu_switch_mouse_model), true, + () -> game.switchMouseModel())); + + options.add(new MenuOption(getString(R.string.game_menu_send_keys), () -> showSpecialKeysMenu())); + + options.add(new MenuOption(getString(R.string.game_menu_hud), true, + () -> game.showHUD())); + + options.add(new MenuOption(getString(R.string.game_menu_switch_keyboard_model), true, + () -> game.showHideKeyboardController())); + + options.add(new MenuOption(getString(R.string.game_menu_switch_virtual_model), true, + () -> game.showHideVirtualController())); + options.add(new MenuOption(getString(R.string.game_menu_switch_virtual_keyboard_model), true, + () -> game.showHidekeyBoardLayoutController())); + + options.add(new MenuOption(getString(R.string.game_menu_switch_touch_sensitivity_model), true, + () -> game.switchTouchSensitivity())); + + if (device != null) { + options.addAll(device.getGameMenuOptions()); + } + + options.add(new MenuOption(getString(R.string.game_menu_cancel), null)); + + showMenuDialog("游戏快捷菜单", options.toArray(new MenuOption[options.size()])); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/limelight/GameSbs.java b/app/src/main/java/com/limelight/GameSbs.java new file mode 100755 index 0000000000..2227f582d6 --- /dev/null +++ b/app/src/main/java/com/limelight/GameSbs.java @@ -0,0 +1,2426 @@ +package com.limelight; + + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.app.Activity; +import android.app.PictureInPictureParams; +import android.app.Service; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.content.res.Configuration; +import android.graphics.Rect; +import android.graphics.SurfaceTexture; +import android.hardware.input.InputManager; +import android.media.AudioManager; +import android.net.ConnectivityManager; +import android.net.wifi.WifiManager; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.IBinder; +import android.util.Rational; +import android.view.Display; +import android.view.InputDevice; +import android.view.KeyCharacterMap; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.Surface; +import android.view.TextureView; +import android.view.View; +import android.view.View.OnGenericMotionListener; +import android.view.View.OnSystemUiVisibilityChangeListener; +import android.view.Window; +import android.view.WindowManager; +import android.view.inputmethod.InputMethodManager; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.CompoundButton; +import android.widget.LinearLayout; +import android.widget.SeekBar; +import android.widget.TextView; +import android.widget.Toast; + +import com.limelight.binding.PlatformBinding; +import com.limelight.binding.audio.AndroidAudioRenderer; +import com.limelight.binding.input.ControllerHandler; +import com.limelight.binding.input.KeyboardTranslator; +import com.limelight.binding.input.driver.UsbDriverService; +import com.limelight.binding.input.evdev.EvdevListener; +import com.limelight.binding.input.touch.AbsoluteTouchContext; +import com.limelight.binding.input.touch.RelativeTouchContext; +import com.limelight.binding.input.touch.TouchContext; +import com.limelight.binding.video.CrashListener; +import com.limelight.binding.video.MediaCodecDecoderRenderer; +import com.limelight.binding.video.MediaCodecHelper; +import com.limelight.binding.video.PerfOverlayListener; +import com.limelight.nvstream.NvConnection; +import com.limelight.nvstream.NvConnectionListener; +import com.limelight.nvstream.StreamConfiguration; +import com.limelight.nvstream.http.ComputerDetails; +import com.limelight.nvstream.http.NvApp; +import com.limelight.nvstream.http.NvHTTP; +import com.limelight.nvstream.input.KeyboardPacket; +import com.limelight.nvstream.input.MouseButtonPacket; +import com.limelight.nvstream.jni.MoonBridge; +import com.limelight.preferences.GlPreferences; +import com.limelight.preferences.PreferenceConfiguration; +import com.limelight.sbs.TextureSurfaceRenderer; +import com.limelight.sbs.VideoTextureRenderer; +import com.limelight.ui.GameGestures; +import com.limelight.ui.SBSStreamView; +import com.limelight.utils.Dialog; +import com.limelight.utils.ServerHelper; +import com.limelight.utils.SpinnerDialog; +import com.limelight.utils.UiHelper; + +import java.io.ByteArrayInputStream; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.Locale; + + +public class GameSbs extends Activity implements TextureView.SurfaceTextureListener, + OnGenericMotionListener, NvConnectionListener, EvdevListener, + OnSystemUiVisibilityChangeListener, GameGestures, SBSStreamView.InputCallbacks, TextureSurfaceRenderer.OnGlReadyListener, + PerfOverlayListener, UsbDriverService.UsbDriverStateListener, View.OnKeyListener { + public static GameSbs instance; + + private int lastButtonState = 0; + + // Only 2 touches are supported + private final TouchContext[] touchContextMap = new TouchContext[2]; + private long threeFingerDownTime = 0; + + private static final int REFERENCE_HORIZ_RES = 1280; + private static final int REFERENCE_VERT_RES = 720; + + private static final int STYLUS_DOWN_DEAD_ZONE_DELAY = 100; + private static final int STYLUS_DOWN_DEAD_ZONE_RADIUS = 20; + + private static final int STYLUS_UP_DEAD_ZONE_DELAY = 150; + private static final int STYLUS_UP_DEAD_ZONE_RADIUS = 50; + + private static final int THREE_FINGER_TAP_THRESHOLD = 300; + + private ControllerHandler controllerHandler; + private KeyboardTranslator keyboardTranslator; + private PreferenceConfiguration prefConfig; + private SharedPreferences tombstonePrefs; + + private NvConnection conn; + private SpinnerDialog spinner; + private boolean displayedFailureDialog = false; + private boolean connecting = false; + public boolean connected = false; + private String pcName; + private String appName; + private NvApp app; + +// private InputCaptureProvider inputCaptureProvider; + private int modifierFlags = 0; + private boolean grabbedInput = true; + private boolean cursorVisible = false; + private boolean waitingForAllModifiersUp = false; + private int specialKeyCode = KeyEvent.KEYCODE_UNKNOWN; + private SBSStreamView streamView; + private long lastAbsTouchUpTime = 0; + private long lastAbsTouchDownTime = 0; + private float lastAbsTouchUpX, lastAbsTouchUpY; + private float lastAbsTouchDownX, lastAbsTouchDownY; + + private boolean isHidingOverlays; + private TextView notificationOverlayView; + private int requestedNotificationOverlayVisibility = View.GONE; + private TextView performanceOverlayView; + + private MediaCodecDecoderRenderer decoderRenderer; + private boolean reportedCrash; + + private WifiManager.WifiLock highPerfWifiLock; + private WifiManager.WifiLock lowLatencyWifiLock; + + private boolean connectedToUsbDriverService = false; + private ServiceConnection usbDriverServiceConnection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName componentName, IBinder iBinder) { + UsbDriverService.UsbDriverBinder binder = (UsbDriverService.UsbDriverBinder) iBinder; + binder.setListener(controllerHandler); + binder.setStateListener(GameSbs.this); + binder.start(); + connectedToUsbDriverService = true; + } + + @Override + public void onServiceDisconnected(ComponentName componentName) { + connectedToUsbDriverService = false; + } + }; + + public static final String EXTRA_HOST = "Host"; + public static final String EXTRA_PORT = "Port"; + public static final String EXTRA_HTTPS_PORT = "HttpsPort"; + public static final String EXTRA_APP_NAME = "AppName"; + public static final String EXTRA_APP_ID = "AppId"; + public static final String EXTRA_UNIQUEID = "UniqueId"; + public static final String EXTRA_PC_UUID = "UUID"; + public static final String EXTRA_PC_NAME = "PcName"; + public static final String EXTRA_APP_HDR = "HDR"; + public static final String EXTRA_SERVER_CERT = "ServerCert"; + + private SharedPreferences sharedpreferences; + + private VideoTextureRenderer renderer; + + private boolean settingsVisible = false; + + + @SuppressLint("MissingInflatedId") + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + instance = this; + + UiHelper.setLocale(this); + + // We don't want a title bar + requestWindowFeature(Window.FEATURE_NO_TITLE); + + // Full-screen + getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + + // If we're going to use immersive mode, we want to have + // the entire screen + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + getWindow().getDecorView().setSystemUiVisibility( + View.SYSTEM_UI_FLAG_LAYOUT_STABLE | + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN); + + getWindow().addFlags(WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN); + } + + // Listen for UI visibility events + getWindow().getDecorView().setOnSystemUiVisibilityChangeListener(this); + + // Change volume button behavior + setVolumeControlStream(AudioManager.STREAM_MUSIC); + + // Inflate the content + setContentView(R.layout.activity_gamevr); + sharedpreferences = getSharedPreferences("VR_PREFERENCES", Context.MODE_PRIVATE); + + // Start the spinner + spinner = SpinnerDialog.displayDialog(this, getResources().getString(R.string.conn_establishing_title), + getResources().getString(R.string.conn_establishing_msg), true); + + // Read the stream preferences + prefConfig = PreferenceConfiguration.readPreferences(this); + tombstonePrefs = GameSbs.this.getSharedPreferences("DecoderTombstone", 0); + + if (prefConfig.stretchVideo || shouldIgnoreInsetsForResolution(prefConfig.width, prefConfig.height)) { + // Allow the activity to layout under notches if the fill-screen option + // was turned on by the user or it's a full-screen native resolution + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + getWindow().getAttributes().layoutInDisplayCutoutMode = + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS; + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + getWindow().getAttributes().layoutInDisplayCutoutMode = + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; + } + } + + // Listen for non-touch events on the game surface + streamView = findViewById(R.id.surface); + streamView.setOnGenericMotionListener(this); + streamView.setOnKeyListener(this); + streamView.setInputCallbacks(this); + streamView.setSurfaceTextureListener(this); + + streamView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + if (GameSbs.this.renderer != null) { + renderer.setZoomedIn(!renderer.isZoomedIn()); + } + } + }); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + // Request unbuffered input event dispatching for all input classes we handle here. + // Without this, input events are buffered to be delivered in lock-step with VBlank, + // artificially increasing input latency while streaming. + streamView.requestUnbufferedDispatch( + InputDevice.SOURCE_CLASS_BUTTON | // Keyboards + InputDevice.SOURCE_CLASS_JOYSTICK | // Gamepads + InputDevice.SOURCE_CLASS_POINTER | // Touchscreens and mice (w/o pointer capture) + InputDevice.SOURCE_CLASS_POSITION | // Touchpads + InputDevice.SOURCE_CLASS_TRACKBALL // Mice (pointer capture) + ); + } + + notificationOverlayView = findViewById(R.id.notificationOverlay); + + performanceOverlayView = findViewById(R.id.performanceOverlay); + +// inputCaptureProvider = InputCaptureManager.getInputCaptureProvider(this, this); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + streamView.setOnCapturedPointerListener(new View.OnCapturedPointerListener() { + @Override + public boolean onCapturedPointer(View view, MotionEvent motionEvent) { + return handleMotionEvent(view, motionEvent); + } + }); + } + + // Warn the user if they're on a metered connection + ConnectivityManager connMgr = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE); + if (connMgr.isActiveNetworkMetered()) { + displayTransientMessage(getResources().getString(R.string.conn_metered)); + } + + // Make sure Wi-Fi is fully powered up + WifiManager wifiMgr = (WifiManager) getApplicationContext().getSystemService(Context.WIFI_SERVICE); + try { + highPerfWifiLock = wifiMgr.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "Moonlight High Perf Lock"); + highPerfWifiLock.setReferenceCounted(false); + highPerfWifiLock.acquire(); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + lowLatencyWifiLock = wifiMgr.createWifiLock(WifiManager.WIFI_MODE_FULL_LOW_LATENCY, "Moonlight Low Latency Lock"); + lowLatencyWifiLock.setReferenceCounted(false); + lowLatencyWifiLock.acquire(); + } + } catch (SecurityException e) { + // Some Samsung Galaxy S10+/S10e devices throw a SecurityException from + // WifiLock.acquire() even though we have android.permission.WAKE_LOCK in our manifest. + e.printStackTrace(); + } + + appName = GameSbs.this.getIntent().getStringExtra(EXTRA_APP_NAME); + pcName = GameSbs.this.getIntent().getStringExtra(EXTRA_PC_NAME); + + String host = GameSbs.this.getIntent().getStringExtra(EXTRA_HOST); + int port = GameSbs.this.getIntent().getIntExtra(EXTRA_PORT, NvHTTP.DEFAULT_HTTP_PORT); + int httpsPort = GameSbs.this.getIntent().getIntExtra(EXTRA_HTTPS_PORT, 0); // 0 is treated as unknown + int appId = GameSbs.this.getIntent().getIntExtra(EXTRA_APP_ID, StreamConfiguration.INVALID_APP_ID); + String uniqueId = GameSbs.this.getIntent().getStringExtra(EXTRA_UNIQUEID); + boolean appSupportsHdr = GameSbs.this.getIntent().getBooleanExtra(EXTRA_APP_HDR, false); + byte[] derCertData = GameSbs.this.getIntent().getByteArrayExtra(EXTRA_SERVER_CERT); + + app = new NvApp(appName != null ? appName : "app", appId, appSupportsHdr); + + X509Certificate serverCert = null; + try { + if (derCertData != null) { + serverCert = (X509Certificate) CertificateFactory.getInstance("X.509") + .generateCertificate(new ByteArrayInputStream(derCertData)); + } + } catch (CertificateException e) { + e.printStackTrace(); + } + + if (appId == StreamConfiguration.INVALID_APP_ID) { + finish(); + return; + } + + // Initialize the MediaCodec helper before creating the decoder + GlPreferences glPrefs = GlPreferences.readPreferences(this); + MediaCodecHelper.initialize(this, glPrefs.glRenderer); + + // Check if the user has enabled HDR + boolean willStreamHdr = false; + if (prefConfig.enableHdr) { + // Start our HDR checklist + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + Display display = getWindowManager().getDefaultDisplay(); + Display.HdrCapabilities hdrCaps = display.getHdrCapabilities(); + + // We must now ensure our display is compatible with HDR10 + if (hdrCaps != null) { + // getHdrCapabilities() returns null on Lenovo Lenovo Mirage Solo (vega), Android 8.0 + for (int hdrType : hdrCaps.getSupportedHdrTypes()) { + if (hdrType == Display.HdrCapabilities.HDR_TYPE_HDR10) { + willStreamHdr = true; + break; + } + } + } + + if (!willStreamHdr) { + // Nope, no HDR for us :( + Toast.makeText(this, "Display does not support HDR10", Toast.LENGTH_LONG).show(); + } + } else { + Toast.makeText(this, "HDR requires Android 7.0 or later", Toast.LENGTH_LONG).show(); + } + } + + // Check if the user has enabled performance stats overlay + if (prefConfig.enablePerfOverlay) { + performanceOverlayView.setVisibility(View.VISIBLE); + } + decoderRenderer = new MediaCodecDecoderRenderer( + this, + prefConfig, + new CrashListener() { + @Override + public void notifyCrash(Exception e) { + // The MediaCodec instance is going down due to a crash + // let's tell the user something when they open the app again + + // We must use commit because the app will crash when we return from this function + tombstonePrefs.edit().putInt("CrashCount", tombstonePrefs.getInt("CrashCount", 0) + 1).commit(); + reportedCrash = true; + } + }, + tombstonePrefs.getInt("CrashCount", 0), + connMgr.isActiveNetworkMetered(), + willStreamHdr, + glPrefs.glRenderer, + this); + + // Don't stream HDR if the decoder can't support it + if (willStreamHdr && !decoderRenderer.isHevcMain10Hdr10Supported() && !decoderRenderer.isAv1Main10Supported()) { + willStreamHdr = false; + Toast.makeText(this, "Decoder does not support HDR10 profile", Toast.LENGTH_LONG).show(); + } + + // Display a message to the user if HEVC was forced on but we still didn't find a decoder + if (prefConfig.videoFormat == PreferenceConfiguration.FormatOption.FORCE_HEVC && !decoderRenderer.isHevcSupported()) { + Toast.makeText(this, "No HEVC decoder found", Toast.LENGTH_LONG).show(); + } + + // Display a message to the user if AV1 was forced on but we still didn't find a decoder + if (prefConfig.videoFormat == PreferenceConfiguration.FormatOption.FORCE_AV1 && !decoderRenderer.isAv1Supported()) { + Toast.makeText(this, "No AV1 decoder found", Toast.LENGTH_LONG).show(); + } + + // H.264 is always supported + int supportedVideoFormats = MoonBridge.VIDEO_FORMAT_H264; + if (decoderRenderer.isHevcSupported()) { + supportedVideoFormats |= MoonBridge.VIDEO_FORMAT_H265; + if (willStreamHdr && decoderRenderer.isHevcMain10Hdr10Supported()) { + supportedVideoFormats |= MoonBridge.VIDEO_FORMAT_H265_MAIN10; + } + } + if (decoderRenderer.isAv1Supported()) { + supportedVideoFormats |= MoonBridge.VIDEO_FORMAT_AV1_MAIN8; + if (willStreamHdr && decoderRenderer.isAv1Main10Supported()) { + supportedVideoFormats |= MoonBridge.VIDEO_FORMAT_AV1_MAIN10; + } + } + + int gamepadMask = ControllerHandler.getAttachedControllerMask(this); + if (!prefConfig.multiController) { + // Always set gamepad 1 present for when multi-controller is + // disabled for games that don't properly support detection + // of gamepads removed and replugged at runtime. + gamepadMask = 1; + } + if (prefConfig.onscreenController) { + // If we're using OSC, always set at least gamepad 1. + gamepadMask |= 1; + } + + // Set to the optimal mode for streaming + float displayRefreshRate = prepareDisplayForRendering(); + LimeLog.info("Display refresh rate: " + displayRefreshRate); + + // If the user requested frame pacing using a capped FPS, we will need to change our + // desired FPS setting here in accordance with the active display refresh rate. + int roundedRefreshRate = Math.round(displayRefreshRate); + int chosenFrameRate = prefConfig.fps; + if (prefConfig.framePacing == PreferenceConfiguration.FRAME_PACING_CAP_FPS) { + if (prefConfig.fps >= roundedRefreshRate) { + if (prefConfig.fps > roundedRefreshRate + 3) { + // Use frame drops when rendering above the screen frame rate + prefConfig.framePacing = PreferenceConfiguration.FRAME_PACING_BALANCED; + LimeLog.info("Using drop mode for FPS > Hz"); + } else if (roundedRefreshRate <= 49) { + // Let's avoid clearly bogus refresh rates and fall back to legacy rendering + prefConfig.framePacing = PreferenceConfiguration.FRAME_PACING_BALANCED; + LimeLog.info("Bogus refresh rate: " + roundedRefreshRate); + } else { + chosenFrameRate = roundedRefreshRate - 1; + LimeLog.info("Adjusting FPS target for screen to " + chosenFrameRate); + } + } + } + + NvApp app=new NvApp(appName != null ? appName : "app", appId, appSupportsHdr); + + StreamConfiguration config = new StreamConfiguration.Builder() + .setResolution(prefConfig.width, prefConfig.height) + .setLaunchRefreshRate(prefConfig.fps) + .setRefreshRate(chosenFrameRate) + .setApp(app) + .setBitrate(prefConfig.bitrate) + .setEnableSops(prefConfig.enableSops) + .enableLocalAudioPlayback(prefConfig.playHostAudio) + .setMaxPacketSize(1392) + .setRemoteConfiguration(StreamConfiguration.STREAM_CFG_AUTO) // NvConnection will perform LAN and VPN detection + .setSupportedVideoFormats(supportedVideoFormats) + .setAttachedGamepadMask(gamepadMask) + .setClientRefreshRateX100((int)(displayRefreshRate * 100)) + .setAudioConfiguration(prefConfig.audioConfiguration) +// .setAudioEncryption(true) + .setColorSpace(decoderRenderer.getPreferredColorSpace()) + .setColorRange(decoderRenderer.getPreferredColorRange()) + .setPersistGamepadsAfterDisconnect(!prefConfig.multiController) + .build(); + + // Initialize the connection + conn = new NvConnection(getApplicationContext(), + new ComputerDetails.AddressTuple(host, port), + httpsPort, uniqueId, config, + PlatformBinding.getCryptoProvider(this), serverCert); + + controllerHandler = new ControllerHandler(this, conn, this, prefConfig); + keyboardTranslator = new KeyboardTranslator(); + + InputManager inputManager = (InputManager) getSystemService(Context.INPUT_SERVICE); + inputManager.registerInputDeviceListener(keyboardTranslator, null); + + // Initialize touch contexts + for (int i = 0; i < touchContextMap.length; i++) { + if (!prefConfig.touchscreenTrackpad) { + touchContextMap[i] = new AbsoluteTouchContext(conn, i, streamView); + } else { + touchContextMap[i] = new RelativeTouchContext(conn, i, + REFERENCE_HORIZ_RES, REFERENCE_VERT_RES, + streamView, prefConfig); + } + } + + // Use sustained performance mode on N+ to ensure consistent + // CPU availability + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + getWindow().setSustainedPerformanceMode(true); + } + + if (prefConfig.usbDriver) { + // Start the USB driver + bindService(new Intent(this, UsbDriverService.class), + usbDriverServiceConnection, Service.BIND_AUTO_CREATE); + } + + if (!decoderRenderer.isAvcSupported()) { + if (spinner != null) { + spinner.dismiss(); + spinner = null; + } + + // If we can't find an AVC decoder, we can't proceed + Dialog.displayDialog(this, getResources().getString(R.string.conn_error_title), + "This device or ROM doesn't support hardware accelerated H.264 playback.", true); + return; + } + // The connection will be started when the surface gets created +// streamView.getHolder().addCallback(this); + + + final Button settingsButton = findViewById(R.id.settingsButton); + settingsButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + settingsVisible = !settingsVisible; + LinearLayout settingsLayout = findViewById(R.id.settingsPanel); + settingsLayout.setVisibility(settingsVisible ? View.VISIBLE : View.GONE); + } + }); + + final SeekBar zoomBar = findViewById(R.id.sizeSeek); + zoomBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int i, boolean b) { + if (GameSbs.this.renderer != null) { + renderer.setZoomFactor((float) (100 - i)); + SharedPreferences.Editor editor = sharedpreferences.edit(); + editor.putFloat("ZOOM_FACTOR", (float) (100 - i)); + editor.apply(); + } + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + } + }); + + final SeekBar distBar = findViewById(R.id.distSeek); + distBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int i, boolean b) { + if (GameSbs.this.renderer != null) { + renderer.setDistortionFactor((float) i); + SharedPreferences.Editor editor = sharedpreferences.edit(); + editor.putFloat("DISTORTION_FACTOR", (float) i); + editor.apply(); + } + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + } + }); + + final CheckBox wrapCheckBox = findViewById(R.id.wrapCheckbox); + wrapCheckBox.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton compoundButton, boolean b) { + if (GameSbs.this.renderer != null) { + renderer.setWrapEnabled(b); + SharedPreferences.Editor editor = sharedpreferences.edit(); + editor.putBoolean("WRAP_ENABLED", b); + editor.apply(); + } + } + }); + + final CheckBox singleView = findViewById(R.id.singleView); + singleView.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton compoundButton, boolean b) { + if (GameSbs.this.renderer != null) { + renderer.setSingleView(b); + SharedPreferences.Editor editor = sharedpreferences.edit(); + editor.putBoolean("SINGLE_VIEW", b); + editor.apply(); + } + } + }); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + + // Hide on-screen overlays in PiP mode + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (isInPictureInPictureMode()) { + isHidingOverlays = true; + + performanceOverlayView.setVisibility(View.GONE); + notificationOverlayView.setVisibility(View.GONE); + + // Disable sensors while in PiP mode + controllerHandler.disableSensors(); + + // Update GameManager state to indicate we're in PiP (still gaming, but interruptible) + UiHelper.notifyStreamEnteringPiP(this); + } else { + isHidingOverlays = false; + + // Restore overlays to previous state when leaving PiP + + if (prefConfig.enablePerfOverlay) { + performanceOverlayView.setVisibility(View.VISIBLE); + } + + notificationOverlayView.setVisibility(requestedNotificationOverlayVisibility); + + // Enable sensors again after exiting PiP + controllerHandler.enableSensors(); + + // Update GameManager state to indicate we're out of PiP (gaming, non-interruptible) + UiHelper.notifyStreamExitingPiP(this); + } + } + } + + @TargetApi(Build.VERSION_CODES.O) + private PictureInPictureParams getPictureInPictureParams(boolean autoEnter) { + PictureInPictureParams.Builder builder = + new PictureInPictureParams.Builder() + .setAspectRatio(new Rational(prefConfig.width, prefConfig.height)) + .setSourceRectHint(new Rect( + streamView.getLeft(), streamView.getTop(), + streamView.getRight(), streamView.getBottom())); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + builder.setAutoEnterEnabled(autoEnter); + builder.setSeamlessResizeEnabled(true); + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (appName != null) { + builder.setTitle(appName); + if (pcName != null) { + builder.setSubtitle(pcName); + } + } else if (pcName != null) { + builder.setTitle(pcName); + } + } + + return builder.build(); + } + + public void setMetaKeyCaptureState(boolean enabled) { + // This uses custom APIs present on some Samsung devices to allow capture of + // meta key events while streaming. + try { + Class semWindowManager = Class.forName("com.samsung.android.view.SemWindowManager"); + Method getInstanceMethod = semWindowManager.getMethod("getInstance"); + Object manager = getInstanceMethod.invoke(null); + + if (manager != null) { + Class[] parameterTypes = new Class[2]; + parameterTypes[0] = ComponentName.class; + parameterTypes[1] = boolean.class; + Method requestMetaKeyEventMethod = semWindowManager.getDeclaredMethod("requestMetaKeyEvent", parameterTypes); + requestMetaKeyEventMethod.invoke(manager, this.getComponentName(), enabled); + } else { + LimeLog.warning("SemWindowManager.getInstance() returned null"); + } + } catch (ClassNotFoundException e) { + e.printStackTrace(); + } catch (NoSuchMethodException e) { + e.printStackTrace(); + } catch (InvocationTargetException e) { + e.printStackTrace(); + } catch (IllegalAccessException e) { + e.printStackTrace(); + } + } + + @Override + public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + + // We can't guarantee the state of modifiers keys which may have + // lifted while focus was not on us. Clear the modifier state. + this.modifierFlags = 0; + + // With Android native pointer capture, capture is lost when focus is lost, + // so it must be requested again when focus is regained. +// inputCaptureProvider.onWindowFocusChanged(hasFocus); + } + + private boolean isRefreshRateEqualMatch(float refreshRate) { + return refreshRate >= prefConfig.fps && + refreshRate <= prefConfig.fps + 3; + } + + private boolean isRefreshRateGoodMatch(float refreshRate) { + return refreshRate >= prefConfig.fps && + Math.round(refreshRate) % prefConfig.fps <= 3; + } + + private boolean shouldIgnoreInsetsForResolution(int width, int height) { + // Never ignore insets for non-native resolutions + if (!PreferenceConfiguration.isNativeResolution(width, height)) { + return false; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + Display display = getWindowManager().getDefaultDisplay(); + for (Display.Mode candidate : display.getSupportedModes()) { + // Ignore insets if this is an exact match for the display resolution + if ((width == candidate.getPhysicalWidth() && height == candidate.getPhysicalHeight()) || + (height == candidate.getPhysicalWidth() && width == candidate.getPhysicalHeight())) { + return true; + } + } + } + + return false; + } + + private boolean mayReduceRefreshRate() { + return prefConfig.framePacing == PreferenceConfiguration.FRAME_PACING_CAP_FPS || + prefConfig.framePacing == PreferenceConfiguration.FRAME_PACING_MAX_SMOOTHNESS || + (prefConfig.framePacing == PreferenceConfiguration.FRAME_PACING_BALANCED && prefConfig.reduceRefreshRate); + } + + private float prepareDisplayForRendering() { + Display display = getWindowManager().getDefaultDisplay(); + WindowManager.LayoutParams windowLayoutParams = getWindow().getAttributes(); + float displayRefreshRate; + + // On M, we can explicitly set the optimal display mode + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + Display.Mode bestMode = display.getMode(); + boolean isNativeResolutionStream = PreferenceConfiguration.isNativeResolution(prefConfig.width, prefConfig.height); + boolean refreshRateIsGood = isRefreshRateGoodMatch(bestMode.getRefreshRate()); + boolean refreshRateIsEqual = isRefreshRateEqualMatch(bestMode.getRefreshRate()); + + LimeLog.info("Current display mode: " + bestMode.getPhysicalWidth() + "x" + + bestMode.getPhysicalHeight() + "x" + bestMode.getRefreshRate()); + + for (Display.Mode candidate : display.getSupportedModes()) { + boolean refreshRateReduced = candidate.getRefreshRate() < bestMode.getRefreshRate(); + boolean resolutionReduced = candidate.getPhysicalWidth() < bestMode.getPhysicalWidth() || + candidate.getPhysicalHeight() < bestMode.getPhysicalHeight(); + boolean resolutionFitsStream = candidate.getPhysicalWidth() >= prefConfig.width && + candidate.getPhysicalHeight() >= prefConfig.height; + + LimeLog.info("Examining display mode: " + candidate.getPhysicalWidth() + "x" + + candidate.getPhysicalHeight() + "x" + candidate.getRefreshRate()); + + if (candidate.getPhysicalWidth() > 4096 && prefConfig.width <= 4096) { + // Avoid resolutions options above 4K to be safe + continue; + } + + // On non-4K streams, we force the resolution to never change unless it's above + // 60 FPS, which may require a resolution reduction due to HDMI bandwidth limitations, + // or it's a native resolution stream. + if (prefConfig.width < 3840 && prefConfig.fps <= 60 && !isNativeResolutionStream) { + if (display.getMode().getPhysicalWidth() != candidate.getPhysicalWidth() || + display.getMode().getPhysicalHeight() != candidate.getPhysicalHeight()) { + continue; + } + } + + // Make sure the resolution doesn't regress unless if it's over 60 FPS + // where we may need to reduce resolution to achieve the desired refresh rate. + if (resolutionReduced && !(prefConfig.fps > 60 && resolutionFitsStream)) { + continue; + } + + if (mayReduceRefreshRate() && refreshRateIsEqual && !isRefreshRateEqualMatch(candidate.getRefreshRate())) { + // If we had an equal refresh rate and this one is not, skip it. In min latency + // mode, we want to always prefer the highest frame rate even though it may cause + // microstuttering. + continue; + } else if (refreshRateIsGood) { + // We've already got a good match, so if this one isn't also good, it's not + // worth considering at all. + if (!isRefreshRateGoodMatch(candidate.getRefreshRate())) { + continue; + } + + if (mayReduceRefreshRate()) { + // User asked for the lowest possible refresh rate, so don't raise it if we + // have a good match already + if (candidate.getRefreshRate() > bestMode.getRefreshRate()) { + continue; + } + } else { + // User asked for the highest possible refresh rate, so don't reduce it if we + // have a good match already + if (refreshRateReduced) { + continue; + } + } + } else if (!isRefreshRateGoodMatch(candidate.getRefreshRate())) { + // We didn't have a good match and this match isn't good either, so just don't + // reduce the refresh rate. + if (refreshRateReduced) { + continue; + } + } else { + // We didn't have a good match and this match is good. Prefer this refresh rate + // even if it reduces the refresh rate. Lowering the refresh rate can be beneficial + // when streaming a 60 FPS stream on a 90 Hz device. We want to select 60 Hz to + // match the frame rate even if the active display mode is 90 Hz. + } + + bestMode = candidate; + refreshRateIsGood = isRefreshRateGoodMatch(candidate.getRefreshRate()); + refreshRateIsEqual = isRefreshRateEqualMatch(candidate.getRefreshRate()); + } + + LimeLog.info("Best display mode: " + bestMode.getPhysicalWidth() + "x" + + bestMode.getPhysicalHeight() + "x" + bestMode.getRefreshRate()); + + // Only apply new window layout parameters if we've actually changed the display mode + if (display.getMode().getModeId() != bestMode.getModeId()) { + // If we only changed refresh rate and we're on an OS that supports Surface.setFrameRate() + // use that instead of using preferredDisplayModeId to avoid the possibility of triggering + // bugs that can cause the system to switch from 4K60 to 4K24 on Chromecast 4K. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || + display.getMode().getPhysicalWidth() != bestMode.getPhysicalWidth() || + display.getMode().getPhysicalHeight() != bestMode.getPhysicalHeight()) { + // Apply the display mode change + windowLayoutParams.preferredDisplayModeId = bestMode.getModeId(); + getWindow().setAttributes(windowLayoutParams); + } else { + LimeLog.info("Using setFrameRate() instead of preferredDisplayModeId due to matching resolution"); + } + } else { + LimeLog.info("Current display mode is already the best display mode"); + } + + displayRefreshRate = bestMode.getRefreshRate(); + } + // On L, we can at least tell the OS that we want a refresh rate + else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + float bestRefreshRate = display.getRefreshRate(); + for (float candidate : display.getSupportedRefreshRates()) { + LimeLog.info("Examining refresh rate: " + candidate); + + if (candidate > bestRefreshRate) { + // Ensure the frame rate stays around 60 Hz for <= 60 FPS streams + if (prefConfig.fps <= 60) { + if (candidate >= 63) { + continue; + } + } + + bestRefreshRate = candidate; + } + } + + LimeLog.info("Selected refresh rate: " + bestRefreshRate); + windowLayoutParams.preferredRefreshRate = bestRefreshRate; + displayRefreshRate = bestRefreshRate; + + // Apply the refresh rate change + getWindow().setAttributes(windowLayoutParams); + } else { + // Otherwise, the active display refresh rate is just + // whatever is currently in use. + displayRefreshRate = display.getRefreshRate(); + } + + streamView.setDesiredAspectRatio((double) 16 / 9); + + if (getPackageManager().hasSystemFeature(PackageManager.FEATURE_TELEVISION) || + getPackageManager().hasSystemFeature(PackageManager.FEATURE_LEANBACK)) { + // TVs may take a few moments to switch refresh rates, and we can probably assume + // it will be eventually activated. + // TODO: Improve this + return displayRefreshRate; + } else { + // Use the lower of the current refresh rate and the selected refresh rate. + // The preferred refresh rate may not actually be applied (ex: Battery Saver mode). + return Math.min(getWindowManager().getDefaultDisplay().getRefreshRate(), displayRefreshRate); + } + } + + @SuppressLint("InlinedApi") + private final Runnable hideSystemUi = new Runnable() { + @Override + public void run() { + // TODO: Do we want to use WindowInsetsController here on R+ instead of + // SYSTEM_UI_FLAG_IMMERSIVE_STICKY? They seem to do the same thing as of S... + + // In multi-window mode on N+, we need to drop our layout flags or we'll + // be drawing underneath the system UI. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isInMultiWindowMode()) { + GameSbs.this.getWindow().getDecorView().setSystemUiVisibility( + View.SYSTEM_UI_FLAG_LAYOUT_STABLE); + } + // Use immersive mode on 4.4+ or standard low profile on previous builds + else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + GameSbs.this.getWindow().getDecorView().setSystemUiVisibility( + View.SYSTEM_UI_FLAG_LAYOUT_STABLE | + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_FULLSCREEN | + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY); + } else { + GameSbs.this.getWindow().getDecorView().setSystemUiVisibility( + View.SYSTEM_UI_FLAG_FULLSCREEN | + View.SYSTEM_UI_FLAG_LOW_PROFILE); + } + } + }; + + private void hideSystemUi(int delay) { + Handler h = getWindow().getDecorView().getHandler(); + if (h != null) { + h.removeCallbacks(hideSystemUi); + h.postDelayed(hideSystemUi, delay); + } + } + + @Override + protected void onDestroy() { + super.onDestroy(); + + instance = null; + + if (controllerHandler != null) { + controllerHandler.destroy(); + } + if (keyboardTranslator != null) { + InputManager inputManager = (InputManager) getSystemService(Context.INPUT_SERVICE); + inputManager.unregisterInputDeviceListener(keyboardTranslator); + } + + if (lowLatencyWifiLock != null) { + lowLatencyWifiLock.release(); + } + if (highPerfWifiLock != null) { + highPerfWifiLock.release(); + } + + if (connectedToUsbDriverService) { + // Unbind from the discovery service + unbindService(usbDriverServiceConnection); + } + } + + @Override + protected void onPause() { + if (isFinishing()) { + // Stop any further input device notifications before we lose focus (and pointer capture) + if (controllerHandler != null) { + controllerHandler.stop(); + } + } + + super.onPause(); + } + + @Override + protected void onStop() { + super.onStop(); + + SpinnerDialog.closeDialogs(this); + Dialog.closeDialogs(); + + if (conn != null) { + int videoFormat = decoderRenderer.getActiveVideoFormat(); + + displayedFailureDialog = true; + stopConnection(); + + if (prefConfig.enableLatencyToast) { + int averageEndToEndLat = decoderRenderer.getAverageEndToEndLatency(); + int averageDecoderLat = decoderRenderer.getAverageDecoderLatency(); + String message = null; + if (averageEndToEndLat > 0) { + message = getResources().getString(R.string.conn_client_latency) + " " + averageEndToEndLat + " ms"; + if (averageDecoderLat > 0) { + message += " (" + getResources().getString(R.string.conn_client_latency_hw) + " " + averageDecoderLat + " ms)"; + } + } else if (averageDecoderLat > 0) { + message = getResources().getString(R.string.conn_hardware_latency) + " " + averageDecoderLat + " ms"; + } + + // Add the video codec to the post-stream toast + if (message != null) { + message += " ["; + + if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_H264) != 0) { + message += "H.264"; + } else if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_H265) != 0) { + message += "HEVC"; + } else if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_AV1) != 0) { + message += "AV1"; + } else { + message += "UNKNOWN"; + } + + if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_10BIT) != 0) { + message += " HDR"; + } + + message += "]"; + } + + if (message != null) { + Toast.makeText(this, message, Toast.LENGTH_LONG).show(); + } + } + + // Clear the tombstone count if we terminated normally + if (!reportedCrash && tombstonePrefs.getInt("CrashCount", 0) != 0) { + tombstonePrefs.edit() + .putInt("CrashCount", 0) + .putInt("LastNotifiedCrashCount", 0) + .apply(); + } + } + + finish(); + } + + // Returns true if the key stroke was consumed + private boolean handleSpecialKeys(int androidKeyCode, boolean down) { + int modifierMask = 0; + int nonModifierKeyCode = KeyEvent.KEYCODE_UNKNOWN; + + if (androidKeyCode == KeyEvent.KEYCODE_CTRL_LEFT || + androidKeyCode == KeyEvent.KEYCODE_CTRL_RIGHT) { + modifierMask = KeyboardPacket.MODIFIER_CTRL; + } else if (androidKeyCode == KeyEvent.KEYCODE_SHIFT_LEFT || + androidKeyCode == KeyEvent.KEYCODE_SHIFT_RIGHT) { + modifierMask = KeyboardPacket.MODIFIER_SHIFT; + } else if (androidKeyCode == KeyEvent.KEYCODE_ALT_LEFT || + androidKeyCode == KeyEvent.KEYCODE_ALT_RIGHT) { + modifierMask = KeyboardPacket.MODIFIER_ALT; + } else if (androidKeyCode == KeyEvent.KEYCODE_META_LEFT || + androidKeyCode == KeyEvent.KEYCODE_META_RIGHT) { + modifierMask = KeyboardPacket.MODIFIER_META; + } else { + nonModifierKeyCode = androidKeyCode; + } + + if (down) { + this.modifierFlags |= modifierMask; + } else { + this.modifierFlags &= ~modifierMask; + } + + // Handle the special combos on the key up + if (waitingForAllModifiersUp || specialKeyCode != KeyEvent.KEYCODE_UNKNOWN) { + if (specialKeyCode == androidKeyCode) { + // If this is a key up for the special key itself, eat that because the host never saw the original key down + return true; + } else if (modifierFlags != 0) { + // While we're waiting for modifiers to come up, eat all key downs and allow all key ups to pass + return down; + } else { + // When all modifiers are up, perform the special action + switch (specialKeyCode) { + // Toggle input grab + case KeyEvent.KEYCODE_Z: +// Handler h = getWindow().getDecorView().getHandler(); +// if (h != null) { +// h.postDelayed(toggleGrab, 250); +// } + break; + + // Quit + case KeyEvent.KEYCODE_Q: + finish(); + break; + // Toggle cursor visibility + case KeyEvent.KEYCODE_C: + break; + + default: + break; + } + + // Reset special key state + specialKeyCode = KeyEvent.KEYCODE_UNKNOWN; + waitingForAllModifiersUp = false; + } + } + // Check if Ctrl+Alt+Shift is down when a non-modifier key is pressed + else if ((modifierFlags & (KeyboardPacket.MODIFIER_CTRL | KeyboardPacket.MODIFIER_ALT | KeyboardPacket.MODIFIER_SHIFT)) == + (KeyboardPacket.MODIFIER_CTRL | KeyboardPacket.MODIFIER_ALT | KeyboardPacket.MODIFIER_SHIFT) && + (down && nonModifierKeyCode != KeyEvent.KEYCODE_UNKNOWN)) { + switch (androidKeyCode) { + case KeyEvent.KEYCODE_Z: + case KeyEvent.KEYCODE_Q: + case KeyEvent.KEYCODE_C: + // Remember that a special key combo was activated, so we can consume all key + // events until the modifiers come up + specialKeyCode = androidKeyCode; + waitingForAllModifiersUp = true; + return true; + + default: + // This isn't a special combo that we consume on the client side + return false; + } + } + + // Not a special combo + return false; + } + + // We cannot simply use modifierFlags for all key event processing, because + // some IMEs will not generate real key events for pressing Shift. Instead + // they will simply send key events with isShiftPressed() returning true, + // and we will need to send the modifier flag ourselves. + private byte getModifierState(KeyEvent event) { + // Start with the global modifier state to ensure we cover the case + // detailed in https://github.com/moonlight-stream/moonlight-android/issues/840 + byte modifier = getModifierState(); + if (event.isShiftPressed()) { + modifier |= KeyboardPacket.MODIFIER_SHIFT; + } + if (event.isCtrlPressed()) { + modifier |= KeyboardPacket.MODIFIER_CTRL; + } + if (event.isAltPressed()) { + modifier |= KeyboardPacket.MODIFIER_ALT; + } + if (event.isMetaPressed()) { + modifier |= KeyboardPacket.MODIFIER_META; + } + return modifier; + } + + private byte getModifierState() { + return (byte) modifierFlags; + } + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + return handleKeyDown(event) || super.onKeyDown(keyCode, event); + } + + @Override + public boolean handleKeyDown(KeyEvent event) { + // Pass-through virtual navigation keys + if ((event.getFlags() & KeyEvent.FLAG_VIRTUAL_HARD_KEY) != 0) { + return false; + } + + // Handle a synthetic back button event that some Android OS versions + // create as a result of a right-click. This event WILL repeat if + // the right mouse button is held down, so we ignore those. + int eventSource = event.getSource(); + if ((eventSource == InputDevice.SOURCE_MOUSE || + eventSource == InputDevice.SOURCE_MOUSE_RELATIVE) && + event.getKeyCode() == KeyEvent.KEYCODE_BACK) { + + // Send the right mouse button event if mouse back and forward + // are disabled. If they are enabled, handleMotionEvent() will take + // care of this. + if (!prefConfig.mouseNavButtons) { + conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_RIGHT); + } + + // Always return true, otherwise the back press will be propagated + // up to the parent and finish the activity. + return true; + } + + boolean handled = false; + + if (ControllerHandler.isGameControllerDevice(event.getDevice())) { + // Always try the controller handler first, unless it's an alphanumeric keyboard device. + // Otherwise, controller handler will eat keyboard d-pad events. + handled = controllerHandler.handleButtonDown(event); + } + + // Try the keyboard handler if it wasn't handled as a game controller + if (!handled) { + // Let this method take duplicate key down events + if (handleSpecialKeys(event.getKeyCode(), true)) { + return true; + } + + // Pass through keyboard input if we're not grabbing + if (!grabbedInput) { + return false; + } + + // We'll send it as a raw key event if we have a key mapping, otherwise we'll send it + // as UTF-8 text (if it's a printable character). + short translated = keyboardTranslator.translate(event.getKeyCode(), event.getDeviceId()); + if (translated == 0) { + // Make sure it has a valid Unicode representation and it's not a dead character + // (which we don't support). If those are true, we can send it as UTF-8 text. + // + // NB: We need to be sure this happens before the getRepeatCount() check because + // UTF-8 events don't auto-repeat on the host side. + int unicodeChar = event.getUnicodeChar(); + if ((unicodeChar & KeyCharacterMap.COMBINING_ACCENT) == 0 && (unicodeChar & KeyCharacterMap.COMBINING_ACCENT_MASK) != 0) { + conn.sendUtf8Text("" + (char) unicodeChar); + return true; + } + + return false; + } + + // Eat repeat down events + if (event.getRepeatCount() > 0) { + return true; + } + + conn.sendKeyboardInput(translated, KeyboardPacket.KEY_DOWN, getModifierState(event), + keyboardTranslator.hasNormalizedMapping(event.getKeyCode(), event.getDeviceId()) ? 0 : MoonBridge.SS_KBE_FLAG_NON_NORMALIZED); + } + + return true; + } + + @Override + public boolean onKeyUp(int keyCode, KeyEvent event) { + return handleKeyUp(event) || super.onKeyUp(keyCode, event); + } + + @Override + public boolean handleKeyUp(KeyEvent event) { + // Pass-through virtual navigation keys + if ((event.getFlags() & KeyEvent.FLAG_VIRTUAL_HARD_KEY) != 0) { + return false; + } + + // Handle a synthetic back button event that some Android OS versions + // create as a result of a right-click. + int eventSource = event.getSource(); + if ((eventSource == InputDevice.SOURCE_MOUSE || + eventSource == InputDevice.SOURCE_MOUSE_RELATIVE) && + event.getKeyCode() == KeyEvent.KEYCODE_BACK) { + + // Send the right mouse button event if mouse back and forward + // are disabled. If they are enabled, handleMotionEvent() will take + // care of this. + if (!prefConfig.mouseNavButtons) { + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_RIGHT); + } + + // Always return true, otherwise the back press will be propagated + // up to the parent and finish the activity. + return true; + } + + boolean handled = false; + if (ControllerHandler.isGameControllerDevice(event.getDevice())) { + // Always try the controller handler first, unless it's an alphanumeric keyboard device. + // Otherwise, controller handler will eat keyboard d-pad events. + handled = controllerHandler.handleButtonUp(event); + } + + // Try the keyboard handler if it wasn't handled as a game controller + if (!handled) { + if (handleSpecialKeys(event.getKeyCode(), false)) { + return true; + } + + // Pass through keyboard input if we're not grabbing + if (!grabbedInput) { + return false; + } + + short translated = keyboardTranslator.translate(event.getKeyCode(), event.getDeviceId()); + if (translated == 0) { + // If we sent this event as UTF-8 on key down, also report that it was handled + // when we get the key up event for it. + int unicodeChar = event.getUnicodeChar(); + return (unicodeChar & KeyCharacterMap.COMBINING_ACCENT) == 0 && (unicodeChar & KeyCharacterMap.COMBINING_ACCENT_MASK) != 0; + } + + conn.sendKeyboardInput(translated, KeyboardPacket.KEY_UP, getModifierState(event), + keyboardTranslator.hasNormalizedMapping(event.getKeyCode(), event.getDeviceId()) ? 0 : MoonBridge.SS_KBE_FLAG_NON_NORMALIZED); + } + + return true; + } + + @Override + public boolean onKeyMultiple(int keyCode, int repeatCount, KeyEvent event) { + return handleKeyMultiple(event) || super.onKeyMultiple(keyCode, repeatCount, event); + } + + private boolean handleKeyMultiple(KeyEvent event) { + // We can receive keys from a software keyboard that don't correspond to any existing + // KEYCODE value. Android will give those to us as an ACTION_MULTIPLE KeyEvent. + // + // Despite the fact that the Android docs say this is unused since API level 29, these + // events are still sent as of Android 13 for the above case. + // + // For other cases of ACTION_MULTIPLE, we will not report those as handled so hopefully + // they will be passed to us again as regular singular key events. + if (event.getKeyCode() != KeyEvent.KEYCODE_UNKNOWN || event.getCharacters() == null) { + return false; + } + + conn.sendUtf8Text(event.getCharacters()); + return true; + } + + private TouchContext getTouchContext(int actionIndex) { + if (actionIndex < touchContextMap.length) { + return touchContextMap[actionIndex]; + } else { + return null; + } + } + + @Override + public void toggleKeyboard() { + LimeLog.info("Toggling keyboard overlay"); + InputMethodManager inputManager = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); + inputManager.toggleSoftInput(0, 0); + } + + private byte getLiTouchTypeFromEvent(MotionEvent event) { + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: + case MotionEvent.ACTION_POINTER_DOWN: + return MoonBridge.LI_TOUCH_EVENT_DOWN; + + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_POINTER_UP: + if ((event.getFlags() & MotionEvent.FLAG_CANCELED) != 0) { + return MoonBridge.LI_TOUCH_EVENT_CANCEL; + } else { + return MoonBridge.LI_TOUCH_EVENT_UP; + } + + case MotionEvent.ACTION_MOVE: + return MoonBridge.LI_TOUCH_EVENT_MOVE; + + case MotionEvent.ACTION_CANCEL: + // ACTION_CANCEL applies to *all* pointers in the gesture, so it maps to CANCEL_ALL + // rather than CANCEL. For a single pointer cancellation, that's indicated via + // FLAG_CANCELED on a ACTION_POINTER_UP. + // https://developer.android.com/develop/ui/views/touch-and-input/gestures/multi + return MoonBridge.LI_TOUCH_EVENT_CANCEL_ALL; + + case MotionEvent.ACTION_HOVER_ENTER: + case MotionEvent.ACTION_HOVER_MOVE: + return MoonBridge.LI_TOUCH_EVENT_HOVER; + + case MotionEvent.ACTION_HOVER_EXIT: + return MoonBridge.LI_TOUCH_EVENT_HOVER_LEAVE; + + case MotionEvent.ACTION_BUTTON_PRESS: + case MotionEvent.ACTION_BUTTON_RELEASE: + return MoonBridge.LI_TOUCH_EVENT_BUTTON_ONLY; + + default: + return -1; + } + } + + private float[] getStreamViewRelativeNormalizedXY(View view, MotionEvent event, int pointerIndex) { + float normalizedX = event.getX(pointerIndex); + float normalizedY = event.getY(pointerIndex); + + // For the containing background view, we must subtract the origin + // of the StreamView to get video-relative coordinates. + if (view != streamView) { + normalizedX -= streamView.getX(); + normalizedY -= streamView.getY(); + } + + normalizedX = Math.max(normalizedX, 0.0f); + normalizedY = Math.max(normalizedY, 0.0f); + + normalizedX = Math.min(normalizedX, streamView.getWidth()); + normalizedY = Math.min(normalizedY, streamView.getHeight()); + + normalizedX /= streamView.getWidth(); + normalizedY /= streamView.getHeight(); + + return new float[]{normalizedX, normalizedY}; + } + + private static float normalizeValueInRange(float value, InputDevice.MotionRange range) { + return (value - range.getMin()) / range.getRange(); + } + + private static float getPressureOrDistance(MotionEvent event, int pointerIndex) { + InputDevice dev = event.getDevice(); + switch (event.getActionMasked()) { + case MotionEvent.ACTION_HOVER_ENTER: + case MotionEvent.ACTION_HOVER_MOVE: + case MotionEvent.ACTION_HOVER_EXIT: + // Hover events report distance + if (dev != null) { + InputDevice.MotionRange distanceRange = dev.getMotionRange(MotionEvent.AXIS_DISTANCE, event.getSource()); + if (distanceRange != null) { + return normalizeValueInRange(event.getAxisValue(MotionEvent.AXIS_DISTANCE, pointerIndex), distanceRange); + } + } + return 0.0f; + + default: + // Other events report pressure + return event.getPressure(pointerIndex); + } + } + + private static short getRotationDegrees(MotionEvent event, int pointerIndex) { + InputDevice dev = event.getDevice(); + if (dev != null) { + if (dev.getMotionRange(MotionEvent.AXIS_ORIENTATION, event.getSource()) != null) { + short rotationDegrees = (short) Math.toDegrees(event.getOrientation(pointerIndex)); + if (rotationDegrees < 0) { + rotationDegrees += 360; + } + return rotationDegrees; + } + } + return MoonBridge.LI_ROT_UNKNOWN; + } + + private static float[] polarToCartesian(float r, float theta) { + return new float[]{(float) (r * Math.cos(theta)), (float) (r * Math.sin(theta))}; + } + + private static float cartesianToR(float[] point) { + return (float) Math.sqrt(Math.pow(point[0], 2) + Math.pow(point[1], 2)); + } + + private float[] getStreamViewNormalizedContactArea(MotionEvent event, int pointerIndex) { + float orientation; + + // If the orientation is unknown, we'll just assume it's at a 45 degree angle and scale it by + // X and Y scaling factors evenly. + if (event.getDevice() == null || event.getDevice().getMotionRange(MotionEvent.AXIS_ORIENTATION, event.getSource()) == null) { + orientation = (float) (Math.PI / 4); + } else { + orientation = event.getOrientation(pointerIndex); + } + + float contactAreaMajor, contactAreaMinor; + switch (event.getActionMasked()) { + // Hover events report the tool size + case MotionEvent.ACTION_HOVER_ENTER: + case MotionEvent.ACTION_HOVER_MOVE: + case MotionEvent.ACTION_HOVER_EXIT: + contactAreaMajor = event.getToolMajor(pointerIndex); + contactAreaMinor = event.getToolMinor(pointerIndex); + break; + + // Other events report contact area + default: + contactAreaMajor = event.getTouchMajor(pointerIndex); + contactAreaMinor = event.getTouchMinor(pointerIndex); + break; + } + + // The contact area major axis is parallel to the orientation, so we simply convert + // polar to cartesian coordinates using the orientation as theta. + float[] contactAreaMajorCartesian = polarToCartesian(contactAreaMajor, orientation); + + // The contact area minor axis is perpendicular to the contact area major axis (and thus + // the orientation), so rotate the orientation angle by 90 degrees. + float[] contactAreaMinorCartesian = polarToCartesian(contactAreaMinor, (float) (orientation + (Math.PI / 2))); + + // Normalize the contact area to the stream view size + contactAreaMajorCartesian[0] = Math.min(Math.abs(contactAreaMajorCartesian[0]), streamView.getWidth()) / streamView.getWidth(); + contactAreaMinorCartesian[0] = Math.min(Math.abs(contactAreaMinorCartesian[0]), streamView.getWidth()) / streamView.getWidth(); + contactAreaMajorCartesian[1] = Math.min(Math.abs(contactAreaMajorCartesian[1]), streamView.getHeight()) / streamView.getHeight(); + contactAreaMinorCartesian[1] = Math.min(Math.abs(contactAreaMinorCartesian[1]), streamView.getHeight()) / streamView.getHeight(); + + // Convert the normalized values back into polar coordinates + return new float[]{cartesianToR(contactAreaMajorCartesian), cartesianToR(contactAreaMinorCartesian)}; + } + + private boolean sendPenEventForPointer(View view, MotionEvent event, byte eventType, byte toolType, int pointerIndex) { + byte penButtons = 0; + if ((event.getButtonState() & MotionEvent.BUTTON_STYLUS_PRIMARY) != 0) { + penButtons |= MoonBridge.LI_PEN_BUTTON_PRIMARY; + } + if ((event.getButtonState() & MotionEvent.BUTTON_STYLUS_SECONDARY) != 0) { + penButtons |= MoonBridge.LI_PEN_BUTTON_SECONDARY; + } + + byte tiltDegrees = MoonBridge.LI_TILT_UNKNOWN; + InputDevice dev = event.getDevice(); + if (dev != null) { + if (dev.getMotionRange(MotionEvent.AXIS_TILT, event.getSource()) != null) { + tiltDegrees = (byte) Math.toDegrees(event.getAxisValue(MotionEvent.AXIS_TILT, pointerIndex)); + } + } + + float[] normalizedCoords = getStreamViewRelativeNormalizedXY(view, event, pointerIndex); + float[] normalizedContactArea = getStreamViewNormalizedContactArea(event, pointerIndex); + return conn.sendPenEvent(eventType, toolType, penButtons, + normalizedCoords[0], normalizedCoords[1], + getPressureOrDistance(event, pointerIndex), + normalizedContactArea[0], normalizedContactArea[1], + getRotationDegrees(event, pointerIndex), tiltDegrees) != MoonBridge.LI_ERR_UNSUPPORTED; + } + + private static byte convertToolTypeToStylusToolType(MotionEvent event, int pointerIndex) { + switch (event.getToolType(pointerIndex)) { + case MotionEvent.TOOL_TYPE_ERASER: + return MoonBridge.LI_TOOL_TYPE_ERASER; + case MotionEvent.TOOL_TYPE_STYLUS: + return MoonBridge.LI_TOOL_TYPE_PEN; + default: + return MoonBridge.LI_TOOL_TYPE_UNKNOWN; + } + } + + private boolean trySendPenEvent(View view, MotionEvent event) { + byte eventType = getLiTouchTypeFromEvent(event); + if (eventType < 0) { + return false; + } + + if (event.getActionMasked() == MotionEvent.ACTION_MOVE) { + // Move events may impact all active pointers + boolean handledStylusEvent = false; + for (int i = 0; i < event.getPointerCount(); i++) { + byte toolType = convertToolTypeToStylusToolType(event, i); + if (toolType == MoonBridge.LI_TOOL_TYPE_UNKNOWN) { + // Not a stylus pointer, so skip it + continue; + } else { + // This pointer is a stylus, so we'll report that we handled this event + handledStylusEvent = true; + } + + if (!sendPenEventForPointer(view, event, eventType, toolType, i)) { + // Pen events aren't supported by the host + return false; + } + } + return handledStylusEvent; + } else if (event.getActionMasked() == MotionEvent.ACTION_CANCEL) { + // Cancel impacts all active pointers + return conn.sendPenEvent(MoonBridge.LI_TOUCH_EVENT_CANCEL_ALL, MoonBridge.LI_TOOL_TYPE_UNKNOWN, (byte) 0, + 0, 0, 0, 0, 0, + MoonBridge.LI_ROT_UNKNOWN, MoonBridge.LI_TILT_UNKNOWN) != MoonBridge.LI_ERR_UNSUPPORTED; + } else { + // Up, Down, and Hover events are specific to the action index + byte toolType = convertToolTypeToStylusToolType(event, event.getActionIndex()); + if (toolType == MoonBridge.LI_TOOL_TYPE_UNKNOWN) { + // Not a stylus event + return false; + } + return sendPenEventForPointer(view, event, eventType, toolType, event.getActionIndex()); + } + } + + private boolean sendTouchEventForPointer(View view, MotionEvent event, byte eventType, int pointerIndex) { + float[] normalizedCoords = getStreamViewRelativeNormalizedXY(view, event, pointerIndex); + float[] normalizedContactArea = getStreamViewNormalizedContactArea(event, pointerIndex); + return conn.sendTouchEvent(eventType, event.getPointerId(pointerIndex), + normalizedCoords[0], normalizedCoords[1], + getPressureOrDistance(event, pointerIndex), + normalizedContactArea[0], normalizedContactArea[1], + getRotationDegrees(event, pointerIndex)) != MoonBridge.LI_ERR_UNSUPPORTED; + } + + private boolean trySendTouchEvent(View view, MotionEvent event) { + byte eventType = getLiTouchTypeFromEvent(event); + if (eventType < 0) { + return false; + } + + if (event.getActionMasked() == MotionEvent.ACTION_MOVE) { + // Move events may impact all active pointers + for (int i = 0; i < event.getPointerCount(); i++) { + if (!sendTouchEventForPointer(view, event, eventType, i)) { + return false; + } + } + return true; + } else if (event.getActionMasked() == MotionEvent.ACTION_CANCEL) { + // Cancel impacts all active pointers + return conn.sendTouchEvent(MoonBridge.LI_TOUCH_EVENT_CANCEL_ALL, 0, + 0, 0, 0, 0, 0, + MoonBridge.LI_ROT_UNKNOWN) != MoonBridge.LI_ERR_UNSUPPORTED; + } else { + // Up, Down, and Hover events are specific to the action index + return sendTouchEventForPointer(view, event, eventType, event.getActionIndex()); + } + } + + // Returns true if the event was consumed + // NB: View is only present if called from a view callback + private boolean handleMotionEvent(View view, MotionEvent event) { + // Pass through mouse/touch/joystick input if we're not grabbing + if (!grabbedInput) { + return false; + } + + int eventSource = event.getSource(); + int deviceSources = event.getDevice() != null ? event.getDevice().getSources() : 0; + if ((eventSource & InputDevice.SOURCE_CLASS_JOYSTICK) != 0) { + if (controllerHandler.handleMotionEvent(event)) { + return true; + } + } else if ((deviceSources & InputDevice.SOURCE_CLASS_JOYSTICK) != 0 && controllerHandler.tryHandleTouchpadEvent(event)) { + return true; + } else if ((eventSource & InputDevice.SOURCE_CLASS_POINTER) != 0 || + (eventSource & InputDevice.SOURCE_CLASS_POSITION) != 0 || + eventSource == InputDevice.SOURCE_MOUSE_RELATIVE) { + // This case is for mice and non-finger touch devices + if (eventSource == InputDevice.SOURCE_MOUSE || + (eventSource & InputDevice.SOURCE_CLASS_POSITION) != 0 || // SOURCE_TOUCHPAD + eventSource == InputDevice.SOURCE_MOUSE_RELATIVE || + (event.getPointerCount() >= 1 && + (event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE || + event.getToolType(0) == MotionEvent.TOOL_TYPE_STYLUS || + event.getToolType(0) == MotionEvent.TOOL_TYPE_ERASER)) || + eventSource == 12290) // 12290 = Samsung DeX mode desktop mouse + { + int buttonState = event.getButtonState(); + int changedButtons = buttonState ^ lastButtonState; + + // The DeX touchpad on the Fold 4 sends proper right click events using BUTTON_SECONDARY, + // but doesn't send BUTTON_PRIMARY for a regular click. Instead it sends ACTION_DOWN/UP, + // so we need to fix that up to look like a sane input event to process it correctly. + if (eventSource == 12290) { + if (event.getActionMasked() == MotionEvent.ACTION_DOWN) { + buttonState |= MotionEvent.BUTTON_PRIMARY; + } else if (event.getAction() == MotionEvent.ACTION_UP) { + buttonState &= ~MotionEvent.BUTTON_PRIMARY; + } else { + // We may be faking the primary button down from a previous event, + // so be sure to add that bit back into the button state. + buttonState |= (lastButtonState & MotionEvent.BUTTON_PRIMARY); + } + + changedButtons = buttonState ^ lastButtonState; + } + + // Ignore mouse input if we're not capturing from our input source +// if (!inputCaptureProvider.isCapturingActive()) { +// // We return true here because otherwise the events may end up causing +// // Android to synthesize d-pad events. +// return true; +// } + + // Always update the position before sending any button events. If we're + // dealing with a stylus without hover support, our position might be + // significantly different than before. +// if (inputCaptureProvider.eventHasRelativeMouseAxes(event)) { +// // Send the deltas straight from the motion event +// short deltaX = (short) inputCaptureProvider.getRelativeAxisX(event); +// short deltaY = (short) inputCaptureProvider.getRelativeAxisY(event); +// +// if (deltaX != 0 || deltaY != 0) { +// if (prefConfig.absoluteMouseMode) { +// // NB: view may be null, but we can unconditionally use streamView because we don't need to adjust +// // relative axis deltas for the position of the streamView within the parent's coordinate system. +// conn.sendMouseMoveAsMousePosition(deltaX, deltaY, (short) streamView.getWidth(), (short) streamView.getHeight()); +// } else { +// conn.sendMouseMove(deltaX, deltaY); +// } +// } +// } else + if ((eventSource & InputDevice.SOURCE_CLASS_POSITION) != 0) { + // If this input device is not associated with the view itself (like a trackpad), + // we'll convert the device-specific coordinates to use to send the cursor position. + // This really isn't ideal but it's probably better than nothing. + // + // Trackpad on newer versions of Android (Oreo and later) should be caught by the + // relative axes case above. If we get here, we're on an older version that doesn't + // support pointer capture. + InputDevice device = event.getDevice(); + if (device != null) { + InputDevice.MotionRange xRange = device.getMotionRange(MotionEvent.AXIS_X, eventSource); + InputDevice.MotionRange yRange = device.getMotionRange(MotionEvent.AXIS_Y, eventSource); + + // All touchpads coordinate planes should start at (0, 0) + if (xRange != null && yRange != null && xRange.getMin() == 0 && yRange.getMin() == 0) { + int xMax = (int) xRange.getMax(); + int yMax = (int) yRange.getMax(); + + // Touchpads must be smaller than (65535, 65535) + if (xMax <= Short.MAX_VALUE && yMax <= Short.MAX_VALUE) { + conn.sendMousePosition((short) event.getX(), (short) event.getY(), + (short) xMax, (short) yMax); + } + } + } + } else if (view != null && trySendPenEvent(view, event)) { + // If our host supports pen events, send it directly + return true; + } else if (view != null) { + // Otherwise send absolute position based on the view for SOURCE_CLASS_POINTER + updateMousePosition(view, event); + } + + if (event.getActionMasked() == MotionEvent.ACTION_SCROLL) { + // Send the vertical scroll packet + conn.sendMouseHighResScroll((short) (event.getAxisValue(MotionEvent.AXIS_VSCROLL) * 120)); + conn.sendMouseHighResHScroll((short) (event.getAxisValue(MotionEvent.AXIS_HSCROLL) * 120)); + } + + if ((changedButtons & MotionEvent.BUTTON_PRIMARY) != 0) { + if ((buttonState & MotionEvent.BUTTON_PRIMARY) != 0) { + conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_LEFT); + } else { + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT); + } + } + + // Mouse secondary or stylus primary is right click (stylus down is left click) + if ((changedButtons & (MotionEvent.BUTTON_SECONDARY | MotionEvent.BUTTON_STYLUS_PRIMARY)) != 0) { + if ((buttonState & (MotionEvent.BUTTON_SECONDARY | MotionEvent.BUTTON_STYLUS_PRIMARY)) != 0) { + conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_RIGHT); + } else { + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_RIGHT); + } + } + + // Mouse tertiary or stylus secondary is middle click + if ((changedButtons & (MotionEvent.BUTTON_TERTIARY | MotionEvent.BUTTON_STYLUS_SECONDARY)) != 0) { + if ((buttonState & (MotionEvent.BUTTON_TERTIARY | MotionEvent.BUTTON_STYLUS_SECONDARY)) != 0) { + conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_MIDDLE); + } else { + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_MIDDLE); + } + } + + if (prefConfig.mouseNavButtons) { + if ((changedButtons & MotionEvent.BUTTON_BACK) != 0) { + if ((buttonState & MotionEvent.BUTTON_BACK) != 0) { + conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_X1); + } else { + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_X1); + } + } + + if ((changedButtons & MotionEvent.BUTTON_FORWARD) != 0) { + if ((buttonState & MotionEvent.BUTTON_FORWARD) != 0) { + conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_X2); + } else { + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_X2); + } + } + } + + // Handle stylus presses + if (event.getPointerCount() == 1 && event.getActionIndex() == 0) { + if (event.getActionMasked() == MotionEvent.ACTION_DOWN) { + if (event.getToolType(0) == MotionEvent.TOOL_TYPE_STYLUS) { + lastAbsTouchDownTime = event.getEventTime(); + lastAbsTouchDownX = event.getX(0); + lastAbsTouchDownY = event.getY(0); + + // Stylus is left click + conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_LEFT); + } else if (event.getToolType(0) == MotionEvent.TOOL_TYPE_ERASER) { + lastAbsTouchDownTime = event.getEventTime(); + lastAbsTouchDownX = event.getX(0); + lastAbsTouchDownY = event.getY(0); + + // Eraser is right click + conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_RIGHT); + } + } else if (event.getActionMasked() == MotionEvent.ACTION_UP || event.getActionMasked() == MotionEvent.ACTION_CANCEL) { + if (event.getToolType(0) == MotionEvent.TOOL_TYPE_STYLUS) { + lastAbsTouchUpTime = event.getEventTime(); + lastAbsTouchUpX = event.getX(0); + lastAbsTouchUpY = event.getY(0); + + // Stylus is left click + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT); + } else if (event.getToolType(0) == MotionEvent.TOOL_TYPE_ERASER) { + lastAbsTouchUpTime = event.getEventTime(); + lastAbsTouchUpX = event.getX(0); + lastAbsTouchUpY = event.getY(0); + + // Eraser is right click + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_RIGHT); + } + } + } + + lastButtonState = buttonState; + } + // This case is for fingers + else { + + // If this is the parent view, we'll offset our coordinates to appear as if they + // are relative to the StreamView like our StreamView touch events are. + float xOffset, yOffset; + if (view != streamView && !prefConfig.touchscreenTrackpad) { + xOffset = -streamView.getX(); + yOffset = -streamView.getY(); + } else { + xOffset = 0.f; + yOffset = 0.f; + } + + int actionIndex = event.getActionIndex(); + + int eventX = (int) (event.getX(actionIndex) + xOffset); + int eventY = (int) (event.getY(actionIndex) + yOffset); + + // Special handling for 3 finger gesture + if (event.getActionMasked() == MotionEvent.ACTION_POINTER_DOWN && + event.getPointerCount() == 3) { + // Three fingers down + threeFingerDownTime = event.getEventTime(); + + // Cancel the first and second touches to avoid + // erroneous events + for (TouchContext aTouchContext : touchContextMap) { + aTouchContext.cancelTouch(); + } + + return true; + } + + // TODO: Re-enable native touch when have a better solution for handling + // cancelled touches from Android gestures and 3 finger taps to activate the software keyboard. + if (!prefConfig.touchscreenTrackpad && trySendTouchEvent(view, event)) { + // If this host supports touch events and absolute touch is enabled, + // send it directly as a touch event. + return true; + } + + TouchContext context = getTouchContext(actionIndex); + if (context == null) { + return false; + } + + switch (event.getActionMasked()) { + case MotionEvent.ACTION_POINTER_DOWN: + case MotionEvent.ACTION_DOWN: + for (TouchContext touchContext : touchContextMap) { + touchContext.setPointerCount(event.getPointerCount()); + } + context.touchDownEvent(eventX, eventY, event.getEventTime(), true); + break; + case MotionEvent.ACTION_POINTER_UP: + case MotionEvent.ACTION_UP: + //是触控板模式 三点呼出软键盘 + if (prefConfig.touchscreenTrackpad) { + if (event.getPointerCount() == 1 && + (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || (event.getFlags() & MotionEvent.FLAG_CANCELED) == 0)) { + // All fingers up + if (event.getEventTime() - threeFingerDownTime < THREE_FINGER_TAP_THRESHOLD) { + // This is a 3 finger tap to bring up the keyboard + toggleKeyboard(); + return true; + } + } + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && (event.getFlags() & MotionEvent.FLAG_CANCELED) != 0) { + context.cancelTouch(); + } else { + context.touchUpEvent(eventX, eventY, event.getEventTime()); + } + + for (TouchContext touchContext : touchContextMap) { + touchContext.setPointerCount(event.getPointerCount() - 1); + } + if (actionIndex == 0 && event.getPointerCount() > 1 && !context.isCancelled()) { + // The original secondary touch now becomes primary + context.touchDownEvent( + (int) (event.getX(1) + xOffset), + (int) (event.getY(1) + yOffset), + event.getEventTime(), false); + } + break; + case MotionEvent.ACTION_MOVE: + // ACTION_MOVE is special because it always has actionIndex == 0 + // We'll call the move handlers for all indexes manually + + // First process the historical events + for (int i = 0; i < event.getHistorySize(); i++) { + for (TouchContext aTouchContextMap : touchContextMap) { + if (aTouchContextMap.getActionIndex() < event.getPointerCount()) { + aTouchContextMap.touchMoveEvent( + (int) (event.getHistoricalX(aTouchContextMap.getActionIndex(), i) + xOffset), + (int) (event.getHistoricalY(aTouchContextMap.getActionIndex(), i) + yOffset), + event.getHistoricalEventTime(i)); + } + } + } + + // Now process the current values + for (TouchContext aTouchContextMap : touchContextMap) { + if (aTouchContextMap.getActionIndex() < event.getPointerCount()) { + aTouchContextMap.touchMoveEvent( + (int) (event.getX(aTouchContextMap.getActionIndex()) + xOffset), + (int) (event.getY(aTouchContextMap.getActionIndex()) + yOffset), + event.getEventTime()); + } + } + break; + case MotionEvent.ACTION_CANCEL: + for (TouchContext aTouchContext : touchContextMap) { + aTouchContext.cancelTouch(); + aTouchContext.setPointerCount(0); + } + break; + default: + return false; + } + } + + // Handled a known source + return true; + } + + // Unknown class + return false; + } + + @Override + public boolean onGenericMotionEvent(MotionEvent event) { + return handleMotionEvent(null, event) || super.onGenericMotionEvent(event); + + } + + private void updateMousePosition(View touchedView, MotionEvent event) { + // X and Y are already relative to the provided view object + float eventX, eventY; + + // For our StreamView itself, we can use the coordinates unmodified. + if (touchedView == streamView) { + eventX = event.getX(0); + eventY = event.getY(0); + } else { + // For the containing background view, we must subtract the origin + // of the StreamView to get video-relative coordinates. + eventX = event.getX(0) - streamView.getX(); + eventY = event.getY(0) - streamView.getY(); + } + + if (event.getPointerCount() == 1 && event.getActionIndex() == 0 && + (event.getToolType(0) == MotionEvent.TOOL_TYPE_ERASER || + event.getToolType(0) == MotionEvent.TOOL_TYPE_STYLUS)) { + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: + case MotionEvent.ACTION_HOVER_ENTER: + case MotionEvent.ACTION_HOVER_EXIT: + case MotionEvent.ACTION_HOVER_MOVE: + if (event.getEventTime() - lastAbsTouchUpTime <= STYLUS_UP_DEAD_ZONE_DELAY && + Math.sqrt(Math.pow(eventX - lastAbsTouchUpX, 2) + Math.pow(eventY - lastAbsTouchUpY, 2)) <= STYLUS_UP_DEAD_ZONE_RADIUS) { + // Enforce a small deadzone between touch up and hover or touch down to allow more precise double-clicking + return; + } + break; + + case MotionEvent.ACTION_MOVE: + case MotionEvent.ACTION_UP: + if (event.getEventTime() - lastAbsTouchDownTime <= STYLUS_DOWN_DEAD_ZONE_DELAY && + Math.sqrt(Math.pow(eventX - lastAbsTouchDownX, 2) + Math.pow(eventY - lastAbsTouchDownY, 2)) <= STYLUS_DOWN_DEAD_ZONE_RADIUS) { + // Enforce a small deadzone between touch down and move or touch up to allow more precise double-clicking + return; + } + break; + } + } + + // We may get values slightly outside our view region on ACTION_HOVER_ENTER and ACTION_HOVER_EXIT. + // Normalize these to the view size. We can't just drop them because we won't always get an event + // right at the boundary of the view, so dropping them would result in our cursor never really + // reaching the sides of the screen. + eventX = Math.min(Math.max(eventX, 0), streamView.getWidth()); + eventY = Math.min(Math.max(eventY, 0), streamView.getHeight()); + + conn.sendMousePosition((short) eventX, (short) eventY, (short) streamView.getWidth(), (short) streamView.getHeight()); + } + + @Override + public boolean onGenericMotion(View view, MotionEvent event) { + return handleMotionEvent(view, event); + } + + @Override + public void stageStarting(final String stage) { + runOnUiThread(new Runnable() { + @Override + public void run() { + if (spinner != null) { + spinner.setMessage(getResources().getString(R.string.conn_starting) + " " + stage); + } + } + }); + } + + @Override + public void stageComplete(String stage) { + + } + + private void stopConnection() { + + if (connecting || connected) { + connecting = connected = false; + controllerHandler.stop(); + + // Update GameManager state to indicate we're no longer in game + UiHelper.notifyStreamEnded(this); + + // Stop may take a few hundred ms to do some network I/O to tell + // the server we're going away and clean up. Let it run in a separate + // thread to keep things smooth for the UI. Inside moonlight-common, + // we prevent another thread from starting a connection before and + // during the process of stopping this one. + new Thread() { + public void run() { + conn.stop(); + } + }.start(); + } + } + + @Override + public void stageFailed(final String stage, final int portFlags, final int errorCode) { + if (spinner != null) { + spinner.dismiss(); + spinner = null; + } + if (!displayedFailureDialog) { + displayedFailureDialog = true; + LimeLog.severe(stage + " failed: " + errorCode); + + Dialog.displayDialog(this, getResources().getString(R.string.conn_error_title), + getResources().getString(R.string.conn_error_msg) + " " + stage, true); + } + } + + @Override + public void connectionTerminated(final int errorCode) { + // Perform a connection test if the failure could be due to a blocked port + // This does network I/O, so don't do it on the main thread. + final int portFlags = MoonBridge.getPortFlagsFromTerminationErrorCode(errorCode); + final int portTestResult = MoonBridge.testClientConnectivity(ServerHelper.CONNECTION_TEST_SERVER, 443, portFlags); + + runOnUiThread(new Runnable() { + @Override + public void run() { + // Let the display go to sleep now + getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + + // Stop processing controller input + controllerHandler.stop(); + + // Ungrab input +// setInputGrabState(false); + + if (!displayedFailureDialog) { + displayedFailureDialog = true; + LimeLog.severe("Connection terminated: " + errorCode); + stopConnection(); + + // Display the error dialog if it was an unexpected termination. + // Otherwise, just finish the activity immediately. + if (errorCode != MoonBridge.ML_ERROR_GRACEFUL_TERMINATION) { + String message; + + if (portTestResult != MoonBridge.ML_TEST_RESULT_INCONCLUSIVE && portTestResult != 0) { + // If we got a blocked result, that supersedes any other error message + message = getResources().getString(R.string.nettest_text_blocked); + } else { + switch (errorCode) { + case MoonBridge.ML_ERROR_NO_VIDEO_TRAFFIC: + message = getResources().getString(R.string.no_video_received_error); + break; + + case MoonBridge.ML_ERROR_NO_VIDEO_FRAME: + message = getResources().getString(R.string.no_frame_received_error); + break; + + case MoonBridge.ML_ERROR_UNEXPECTED_EARLY_TERMINATION: + case MoonBridge.ML_ERROR_PROTECTED_CONTENT: + message = getResources().getString(R.string.early_termination_error); + break; + + case MoonBridge.ML_ERROR_FRAME_CONVERSION: + message = getResources().getString(R.string.frame_conversion_error); + break; + + default: + String errorCodeString; + // We'll assume large errors are hex values + if (Math.abs(errorCode) > 1000) { + errorCodeString = Integer.toHexString(errorCode); + } else { + errorCodeString = Integer.toString(errorCode); + } + message = getResources().getString(R.string.conn_terminated_msg) + "\n\n" + + getResources().getString(R.string.error_code_prefix) + " " + errorCodeString; + break; + } + } + + if (portFlags != 0) { + message += "\n\n" + getResources().getString(R.string.check_ports_msg) + "\n" + + MoonBridge.stringifyPortFlags(portFlags, "\n"); + } + + Dialog.displayDialog(GameSbs.this, getResources().getString(R.string.conn_terminated_title), + message, true); + } else { + finish(); + } + } + } + }); + } + + @Override + public void connectionStatusUpdate(final int connectionStatus) { + runOnUiThread(new Runnable() { + @Override + public void run() { + if (prefConfig.disableWarnings) { + return; + } + + if (connectionStatus == MoonBridge.CONN_STATUS_POOR) { + if (prefConfig.bitrate > 5000) { + notificationOverlayView.setText(getResources().getString(R.string.slow_connection_msg)); + } else { + notificationOverlayView.setText(getResources().getString(R.string.poor_connection_msg)); + } + + requestedNotificationOverlayVisibility = View.VISIBLE; + } else if (connectionStatus == MoonBridge.CONN_STATUS_OKAY) { + requestedNotificationOverlayVisibility = View.GONE; + } + + if (!isHidingOverlays) { + notificationOverlayView.setVisibility(requestedNotificationOverlayVisibility); + } + } + }); + } + + @Override + public void connectionStarted() { + runOnUiThread(new Runnable() { + @Override + public void run() { + if (spinner != null) { + spinner.dismiss(); + spinner = null; + } + + connected = true; + connecting = false; + // Hide the mouse cursor now after a short delay. + // Doing it before dismissing the spinner seems to be undone + // when the spinner gets displayed. On Android Q, even now + // is too early to capture. We will delay a second to allow + // the spinner to dismiss before capturing. +// Handler h = new Handler(); +// h.postDelayed(new Runnable() { +// @Override +// public void run() { +// setInputGrabState(true); +// } +// }, 500); + + // Keep the display on + getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + + // Update GameManager state to indicate we're in game + UiHelper.notifyStreamConnected(GameSbs.this); + + hideSystemUi(1000); + } + }); + } + + @Override + public void displayMessage(final String message) { + runOnUiThread(new Runnable() { + @Override + public void run() { + Toast.makeText(GameSbs.this, message, Toast.LENGTH_LONG).show(); + } + }); + } + + @Override + public void displayTransientMessage(final String message) { + if (!prefConfig.disableWarnings) { + runOnUiThread(new Runnable() { + @Override + public void run() { + Toast.makeText(GameSbs.this, message, Toast.LENGTH_LONG).show(); + } + }); + } + } + + @Override + public void rumble(short controllerNumber, short lowFreqMotor, short highFreqMotor) { + LimeLog.info(String.format((Locale) null, "Rumble on gamepad %d: %04x %04x", controllerNumber, lowFreqMotor, highFreqMotor)); + + controllerHandler.handleRumble(controllerNumber, lowFreqMotor, highFreqMotor); + } + + @Override + public void rumbleTriggers(short controllerNumber, short leftTrigger, short rightTrigger) { + LimeLog.info(String.format((Locale) null, "Rumble on gamepad triggers %d: %04x %04x", controllerNumber, leftTrigger, rightTrigger)); + + controllerHandler.handleRumbleTriggers(controllerNumber, leftTrigger, rightTrigger); + } + + @Override + public void setHdrMode(boolean enabled, byte[] hdrMetadata) { + LimeLog.info("Display HDR mode: " + (enabled ? "enabled" : "disabled")); + decoderRenderer.setHdrMode(enabled, hdrMetadata); + } + + @Override + public void setMotionEventState(short controllerNumber, byte motionType, short reportRateHz) { + controllerHandler.handleSetMotionEventState(controllerNumber, motionType, reportRateHz); + } + + @Override + public void setControllerLED(short controllerNumber, byte r, byte g, byte b) { + controllerHandler.handleSetControllerLED(controllerNumber, r, g, b); + } + + + @Override + public void mouseMove(int deltaX, int deltaY) { + conn.sendMouseMove((short) deltaX, (short) deltaY); + } + + @Override + public void mouseButtonEvent(int buttonId, boolean down) { + byte buttonIndex; + + switch (buttonId) { + case EvdevListener.BUTTON_LEFT: + buttonIndex = MouseButtonPacket.BUTTON_LEFT; + break; + case EvdevListener.BUTTON_MIDDLE: + buttonIndex = MouseButtonPacket.BUTTON_MIDDLE; + break; + case EvdevListener.BUTTON_RIGHT: + buttonIndex = MouseButtonPacket.BUTTON_RIGHT; + break; + case EvdevListener.BUTTON_X1: + buttonIndex = MouseButtonPacket.BUTTON_X1; + break; + case EvdevListener.BUTTON_X2: + buttonIndex = MouseButtonPacket.BUTTON_X2; + break; + default: + LimeLog.warning("Unhandled button: " + buttonId); + return; + } + + if (down) { + conn.sendMouseButtonDown(buttonIndex); + } else { + conn.sendMouseButtonUp(buttonIndex); + } + } + + @Override + public void mouseVScroll(byte amount) { + conn.sendMouseScroll(amount); + } + + @Override + public void mouseHScroll(byte amount) { + conn.sendMouseHScroll(amount); + } + + @Override + public void keyboardEvent(boolean buttonDown, short keyCode) { + short keyMap = keyboardTranslator.translate(keyCode, -1); + if (keyMap != 0) { + // handleSpecialKeys() takes the Android keycode + if (handleSpecialKeys(keyCode, buttonDown)) { + return; + } + + if (buttonDown) { + conn.sendKeyboardInput(keyMap, KeyboardPacket.KEY_DOWN, getModifierState(), (byte) 0); + } else { + conn.sendKeyboardInput(keyMap, KeyboardPacket.KEY_UP, getModifierState(), (byte) 0); + } + } + } + + @Override + public void onSystemUiVisibilityChange(int visibility) { + // Don't do anything if we're not connected + if (!connected) { + return; + } + + // This flag is set for all devices + if ((visibility & View.SYSTEM_UI_FLAG_FULLSCREEN) == 0) { + hideSystemUi(2000); + } + // This flag is only set on 4.4+ + else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT && + (visibility & View.SYSTEM_UI_FLAG_HIDE_NAVIGATION) == 0) { + hideSystemUi(2000); + } + // This flag is only set before 4.4+ + else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT && + (visibility & View.SYSTEM_UI_FLAG_LOW_PROFILE) == 0) { + hideSystemUi(2000); + } + } + + @Override + public void onPerfUpdate(final String text) { + runOnUiThread(new Runnable() { + @Override + public void run() { + performanceOverlayView.setText(text); + } + }); + } + + @Override + public void onUsbPermissionPromptStarting() { + // Disable PiP auto-enter while the USB permission prompt is on-screen. This prevents + // us from entering PiP while the user is interacting with the OS permission dialog. + } + + @Override + public void onUsbPermissionPromptCompleted() { + } + + @Override + public boolean onKey(View view, int keyCode, KeyEvent keyEvent) { + switch (keyEvent.getAction()) { + case KeyEvent.ACTION_DOWN: + return handleKeyDown(keyEvent); + case KeyEvent.ACTION_UP: + return handleKeyUp(keyEvent); + case KeyEvent.ACTION_MULTIPLE: + return handleKeyMultiple(keyEvent); + default: + return false; + } + } + + private int surfaceWidth; + private int surfaceHeight; + + @Override + public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) { + surfaceWidth = width; + surfaceHeight = height; + + if (!connected && !connecting) { + connecting = true; + + renderer = new VideoTextureRenderer(this, streamView.getSurfaceTexture(), surfaceWidth, surfaceHeight, this); + renderer.setVideoSize(prefConfig.width, prefConfig.height); + + LimeLog.info("----surface:" + surfaceWidth + " x " + surfaceHeight + ";prefConfig=" + prefConfig.width + " x " + prefConfig.height); + renderer.setZoomFactor(sharedpreferences.getFloat("ZOOM_FACTOR", 50)); + renderer.setDistortionFactor(sharedpreferences.getFloat("DISTORTION_FACTOR", 81)); + renderer.setWrapEnabled(sharedpreferences.getBoolean("WRAP_ENABLED", true)); + renderer.setSingleView(sharedpreferences.getBoolean("SINGLE_VIEW", false)); + } + } + + @Override + public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width, int height) { + //To change body of implemented methods use File | Settings | File Templates. + } + + @Override + public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) { + decoderRenderer.stop(); + if (connected) { + stopConnection(); + } + return false; //To change body of implemented methods use File | Settings | File Templates. + } + + @Override + public void onSurfaceTextureUpdated(SurfaceTexture surface) { + //To change body of implemented methods use File | Settings | File Templates. + } + + @Override + public void onGlReady() { + decoderRenderer.setRenderTarget(new Surface(renderer.getVideoTexture())); + conn.start(new AndroidAudioRenderer(GameSbs.this, prefConfig.enableAudioFx), + decoderRenderer, GameSbs.this); + } +} diff --git a/app/src/main/java/com/limelight/HelpActivity.java b/app/src/main/java/com/limelight/HelpActivity.java old mode 100644 new mode 100755 index 25b125122d..f2603c396a --- a/app/src/main/java/com/limelight/HelpActivity.java +++ b/app/src/main/java/com/limelight/HelpActivity.java @@ -1,116 +1,116 @@ -package com.limelight; - -import android.app.Activity; -import android.graphics.Bitmap; -import android.os.Build; -import android.os.Bundle; -import android.webkit.WebView; -import android.webkit.WebViewClient; -import android.window.OnBackInvokedCallback; -import android.window.OnBackInvokedDispatcher; - -import com.limelight.utils.SpinnerDialog; - -public class HelpActivity extends Activity { - - private SpinnerDialog loadingDialog; - private WebView webView; - - private boolean backCallbackRegistered; - private OnBackInvokedCallback onBackInvokedCallback; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - onBackInvokedCallback = new OnBackInvokedCallback() { - @Override - public void onBackInvoked() { - // We should always be able to go back because we unregister our callback - // when we can't go back. Nonetheless, we will still check anyway. - if (webView.canGoBack()) { - webView.goBack(); - } - } - }; - } - - webView = new WebView(this); - setContentView(webView); - - // These allow the user to zoom the page - webView.getSettings().setBuiltInZoomControls(true); - webView.getSettings().setDisplayZoomControls(false); - - // This sets the view to display the whole page by default - webView.getSettings().setUseWideViewPort(true); - webView.getSettings().setLoadWithOverviewMode(true); - - // This allows the links to places on the same page to work - webView.getSettings().setJavaScriptEnabled(true); - - webView.setWebViewClient(new WebViewClient() { - @Override - public void onPageStarted(WebView view, String url, Bitmap favicon) { - if (loadingDialog == null) { - loadingDialog = SpinnerDialog.displayDialog(HelpActivity.this, - getResources().getString(R.string.help_loading_title), - getResources().getString(R.string.help_loading_msg), false); - } - - refreshBackDispatchState(); - } - - @Override - public void onPageFinished(WebView view, String url) { - if (loadingDialog != null) { - loadingDialog.dismiss(); - loadingDialog = null; - } - - refreshBackDispatchState(); - } - }); - - webView.loadUrl(getIntent().getData().toString()); - } - - private void refreshBackDispatchState() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - if (webView.canGoBack() && !backCallbackRegistered) { - getOnBackInvokedDispatcher().registerOnBackInvokedCallback( - OnBackInvokedDispatcher.PRIORITY_DEFAULT, onBackInvokedCallback); - backCallbackRegistered = true; - } - else if (!webView.canGoBack() && backCallbackRegistered) { - getOnBackInvokedDispatcher().unregisterOnBackInvokedCallback(onBackInvokedCallback); - backCallbackRegistered = false; - } - } - } - - @Override - protected void onDestroy() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - if (backCallbackRegistered) { - getOnBackInvokedDispatcher().unregisterOnBackInvokedCallback(onBackInvokedCallback); - } - } - - super.onDestroy(); - } - - @Override - // NOTE: This will NOT be called on Android 13+ with android:enableOnBackInvokedCallback="true" - public void onBackPressed() { - // Back goes back through the WebView history - // until no more history remains - if (webView.canGoBack()) { - webView.goBack(); - } - else { - super.onBackPressed(); - } - } -} +package com.limelight; + +import android.app.Activity; +import android.graphics.Bitmap; +import android.os.Build; +import android.os.Bundle; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import android.window.OnBackInvokedCallback; +import android.window.OnBackInvokedDispatcher; + +import com.limelight.utils.SpinnerDialog; + +public class HelpActivity extends Activity { + + private SpinnerDialog loadingDialog; + private WebView webView; + + private boolean backCallbackRegistered; + private OnBackInvokedCallback onBackInvokedCallback; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + onBackInvokedCallback = new OnBackInvokedCallback() { + @Override + public void onBackInvoked() { + // We should always be able to go back because we unregister our callback + // when we can't go back. Nonetheless, we will still check anyway. + if (webView.canGoBack()) { + webView.goBack(); + } + } + }; + } + + webView = new WebView(this); + setContentView(webView); + + // These allow the user to zoom the page + webView.getSettings().setBuiltInZoomControls(true); + webView.getSettings().setDisplayZoomControls(false); + + // This sets the view to display the whole page by default + webView.getSettings().setUseWideViewPort(true); + webView.getSettings().setLoadWithOverviewMode(true); + + // This allows the links to places on the same page to work + webView.getSettings().setJavaScriptEnabled(true); + + webView.setWebViewClient(new WebViewClient() { + @Override + public void onPageStarted(WebView view, String url, Bitmap favicon) { + if (loadingDialog == null) { + loadingDialog = SpinnerDialog.displayDialog(HelpActivity.this, + getResources().getString(R.string.help_loading_title), + getResources().getString(R.string.help_loading_msg), false); + } + + refreshBackDispatchState(); + } + + @Override + public void onPageFinished(WebView view, String url) { + if (loadingDialog != null) { + loadingDialog.dismiss(); + loadingDialog = null; + } + + refreshBackDispatchState(); + } + }); + + webView.loadUrl(getIntent().getData().toString()); + } + + private void refreshBackDispatchState() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (webView.canGoBack() && !backCallbackRegistered) { + getOnBackInvokedDispatcher().registerOnBackInvokedCallback( + OnBackInvokedDispatcher.PRIORITY_DEFAULT, onBackInvokedCallback); + backCallbackRegistered = true; + } + else if (!webView.canGoBack() && backCallbackRegistered) { + getOnBackInvokedDispatcher().unregisterOnBackInvokedCallback(onBackInvokedCallback); + backCallbackRegistered = false; + } + } + } + + @Override + protected void onDestroy() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (backCallbackRegistered) { + getOnBackInvokedDispatcher().unregisterOnBackInvokedCallback(onBackInvokedCallback); + } + } + + super.onDestroy(); + } + + @Override + // NOTE: This will NOT be called on Android 13+ with android:enableOnBackInvokedCallback="true" + public void onBackPressed() { + // Back goes back through the WebView history + // until no more history remains + if (webView.canGoBack()) { + webView.goBack(); + } + else { + super.onBackPressed(); + } + } +} diff --git a/app/src/main/java/com/limelight/KeyboardAccessibilityService.java b/app/src/main/java/com/limelight/KeyboardAccessibilityService.java new file mode 100755 index 0000000000..fa2e3225ea --- /dev/null +++ b/app/src/main/java/com/limelight/KeyboardAccessibilityService.java @@ -0,0 +1,72 @@ +package com.limelight; + +import android.accessibilityservice.AccessibilityService; +import android.accessibilityservice.AccessibilityServiceInfo; +import android.view.KeyEvent; +import android.view.accessibility.AccessibilityEvent; +import android.widget.Toast; + +import java.util.Arrays; +import java.util.List; + +public class KeyboardAccessibilityService extends AccessibilityService { + + //不屏蔽的按键列表 + private final static List BLACKLIST_KEYS = Arrays.asList( + KeyEvent.KEYCODE_VOLUME_UP, + KeyEvent.KEYCODE_VOLUME_DOWN, + KeyEvent.KEYCODE_POWER + ); + + @Override + public boolean onKeyEvent(KeyEvent event) { + int action = event.getAction(); + int keyCode = event.getKeyCode(); +// Toast.makeText(getApplicationContext(),"scancode:"+event.getScanCode()+",code:"+event.getKeyCode(),Toast.LENGTH_LONG).show(); + //主要解决系统自带快捷键在pc端无法使用问题 home键 scancode=172 code- 3 + if (Game.instance != null && Game.instance.connected && !BLACKLIST_KEYS.contains(keyCode)) { + + if (action == KeyEvent.ACTION_DOWN) { + //fix 小米平板esc键按钮映射错误 KEYCODE_BACK=4 + if(event.getScanCode()==1){ + Game.instance.handleKeyDown(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ESCAPE)); + return true; + } + Game.instance.handleKeyDown(event); + return true; + } else if (action == KeyEvent.ACTION_UP) { + //fix 小米平板esc键按钮映射错误 KEYCODE_BACK=4 + if(event.getScanCode()==1){ + Game.instance.handleKeyUp(new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ESCAPE)); + return true; + } + Game.instance.handleKeyUp(event); + return true; + } + } + + return super.onKeyEvent(event); + } + + @Override + public void onServiceConnected() { + LimeLog.info("Keyboard service is connected"); + AccessibilityServiceInfo info = new AccessibilityServiceInfo(); + info.packageNames = new String[] { BuildConfig.APPLICATION_ID }; + info.eventTypes = AccessibilityEvent.TYPES_ALL_MASK; + info.notificationTimeout = 100; + info.flags = AccessibilityServiceInfo.FLAG_REQUEST_FILTER_KEY_EVENTS; + info.feedbackType = AccessibilityServiceInfo.FEEDBACK_SPOKEN; + setServiceInfo(info); + } + + @Override + public void onAccessibilityEvent(AccessibilityEvent accessibilityEvent) { +// LimeLog.info("onAccessibilityEvent:"+accessibilityEvent.toString()); + } + @Override + public void onInterrupt() { + + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/limelight/LimeLog.java b/app/src/main/java/com/limelight/LimeLog.java old mode 100644 new mode 100755 index ba0ba298cc..07f21b9e0b --- a/app/src/main/java/com/limelight/LimeLog.java +++ b/app/src/main/java/com/limelight/LimeLog.java @@ -1,25 +1,25 @@ -package com.limelight; - -import java.io.IOException; -import java.util.logging.FileHandler; -import java.util.logging.Logger; - -public class LimeLog { - private static final Logger LOGGER = Logger.getLogger(LimeLog.class.getName()); - - public static void info(String msg) { - LOGGER.info(msg); - } - - public static void warning(String msg) { - LOGGER.warning(msg); - } - - public static void severe(String msg) { - LOGGER.severe(msg); - } - - public static void setFileHandler(String fileName) throws IOException { - LOGGER.addHandler(new FileHandler(fileName)); - } -} +package com.limelight; + +import java.io.IOException; +import java.util.logging.FileHandler; +import java.util.logging.Logger; + +public class LimeLog { + private static final Logger LOGGER = Logger.getLogger(LimeLog.class.getName()); + + public static void info(String msg) { + LOGGER.info(msg); + } + + public static void warning(String msg) { + LOGGER.warning(msg); + } + + public static void severe(String msg) { + LOGGER.severe(msg); + } + + public static void setFileHandler(String fileName) throws IOException { + LOGGER.addHandler(new FileHandler(fileName)); + } +} diff --git a/app/src/main/java/com/limelight/PcView.java b/app/src/main/java/com/limelight/PcView.java old mode 100644 new mode 100755 index 4ec6094f6e..365c8b0934 --- a/app/src/main/java/com/limelight/PcView.java +++ b/app/src/main/java/com/limelight/PcView.java @@ -1,788 +1,788 @@ -package com.limelight; - -import java.io.FileNotFoundException; -import java.io.IOException; -import java.net.UnknownHostException; - -import com.limelight.binding.PlatformBinding; -import com.limelight.binding.crypto.AndroidCryptoProvider; -import com.limelight.computers.ComputerManagerListener; -import com.limelight.computers.ComputerManagerService; -import com.limelight.grid.PcGridAdapter; -import com.limelight.grid.assets.DiskAssetLoader; -import com.limelight.nvstream.http.ComputerDetails; -import com.limelight.nvstream.http.NvApp; -import com.limelight.nvstream.http.NvHTTP; -import com.limelight.nvstream.http.PairingManager; -import com.limelight.nvstream.http.PairingManager.PairState; -import com.limelight.nvstream.wol.WakeOnLanSender; -import com.limelight.preferences.AddComputerManually; -import com.limelight.preferences.GlPreferences; -import com.limelight.preferences.PreferenceConfiguration; -import com.limelight.preferences.StreamSettings; -import com.limelight.ui.AdapterFragment; -import com.limelight.ui.AdapterFragmentCallbacks; -import com.limelight.utils.Dialog; -import com.limelight.utils.HelpLauncher; -import com.limelight.utils.ServerHelper; -import com.limelight.utils.ShortcutHelper; -import com.limelight.utils.UiHelper; - -import android.app.Activity; -import android.app.ActivityManager; -import android.app.Service; -import android.content.ComponentName; -import android.content.Intent; -import android.content.ServiceConnection; -import android.content.res.Configuration; -import android.opengl.GLSurfaceView; -import android.os.Build; -import android.os.Bundle; -import android.os.IBinder; -import android.preference.PreferenceManager; -import android.view.ContextMenu; -import android.view.Menu; -import android.view.MenuItem; -import android.view.View; -import android.view.ContextMenu.ContextMenuInfo; -import android.view.View.OnClickListener; -import android.widget.AbsListView; -import android.widget.AdapterView; -import android.widget.AdapterView.OnItemClickListener; -import android.widget.ImageButton; -import android.widget.RelativeLayout; -import android.widget.Toast; -import android.widget.AdapterView.AdapterContextMenuInfo; - -import org.xmlpull.v1.XmlPullParserException; - -import javax.microedition.khronos.egl.EGLConfig; -import javax.microedition.khronos.opengles.GL10; - -public class PcView extends Activity implements AdapterFragmentCallbacks { - private RelativeLayout noPcFoundLayout; - private PcGridAdapter pcGridAdapter; - private ShortcutHelper shortcutHelper; - private ComputerManagerService.ComputerManagerBinder managerBinder; - private boolean freezeUpdates, runningPolling, inForeground, completeOnCreateCalled; - private final ServiceConnection serviceConnection = new ServiceConnection() { - public void onServiceConnected(ComponentName className, IBinder binder) { - final ComputerManagerService.ComputerManagerBinder localBinder = - ((ComputerManagerService.ComputerManagerBinder)binder); - - // Wait in a separate thread to avoid stalling the UI - new Thread() { - @Override - public void run() { - // Wait for the binder to be ready - localBinder.waitForReady(); - - // Now make the binder visible - managerBinder = localBinder; - - // Start updates - startComputerUpdates(); - - // Force a keypair to be generated early to avoid discovery delays - new AndroidCryptoProvider(PcView.this).getClientCertificate(); - } - }.start(); - } - - public void onServiceDisconnected(ComponentName className) { - managerBinder = null; - } - }; - - @Override - public void onConfigurationChanged(Configuration newConfig) { - super.onConfigurationChanged(newConfig); - - // Only reinitialize views if completeOnCreate() was called - // before this callback. If it was not, completeOnCreate() will - // handle initializing views with the config change accounted for. - // This is not prone to races because both callbacks are invoked - // in the main thread. - if (completeOnCreateCalled) { - // Reinitialize views just in case orientation changed - initializeViews(); - } - } - - private final static int PAIR_ID = 2; - private final static int UNPAIR_ID = 3; - private final static int WOL_ID = 4; - private final static int DELETE_ID = 5; - private final static int RESUME_ID = 6; - private final static int QUIT_ID = 7; - private final static int VIEW_DETAILS_ID = 8; - private final static int FULL_APP_LIST_ID = 9; - private final static int TEST_NETWORK_ID = 10; - private final static int GAMESTREAM_EOL_ID = 11; - - private void initializeViews() { - setContentView(R.layout.activity_pc_view); - - UiHelper.notifyNewRootView(this); - - // Allow floating expanded PiP overlays while browsing PCs - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - setShouldDockBigOverlays(false); - } - - // Set default preferences if we've never been run - PreferenceManager.setDefaultValues(this, R.xml.preferences, false); - - // Set the correct layout for the PC grid - pcGridAdapter.updateLayoutWithPreferences(this, PreferenceConfiguration.readPreferences(this)); - - // Setup the list view - ImageButton settingsButton = findViewById(R.id.settingsButton); - ImageButton addComputerButton = findViewById(R.id.manuallyAddPc); - ImageButton helpButton = findViewById(R.id.helpButton); - - settingsButton.setOnClickListener(new OnClickListener() { - @Override - public void onClick(View v) { - startActivity(new Intent(PcView.this, StreamSettings.class)); - } - }); - addComputerButton.setOnClickListener(new OnClickListener() { - @Override - public void onClick(View v) { - Intent i = new Intent(PcView.this, AddComputerManually.class); - startActivity(i); - } - }); - helpButton.setOnClickListener(new OnClickListener() { - @Override - public void onClick(View v) { - HelpLauncher.launchSetupGuide(PcView.this); - } - }); - - // Amazon review didn't like the help button because the wiki was not entirely - // navigable via the Fire TV remote (though the relevant parts were). Let's hide - // it on Fire TV. - if (getPackageManager().hasSystemFeature("amazon.hardware.fire_tv")) { - helpButton.setVisibility(View.GONE); - } - - getFragmentManager().beginTransaction() - .replace(R.id.pcFragmentContainer, new AdapterFragment()) - .commitAllowingStateLoss(); - - noPcFoundLayout = findViewById(R.id.no_pc_found_layout); - if (pcGridAdapter.getCount() == 0) { - noPcFoundLayout.setVisibility(View.VISIBLE); - } - else { - noPcFoundLayout.setVisibility(View.INVISIBLE); - } - pcGridAdapter.notifyDataSetChanged(); - } - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - // Assume we're in the foreground when created to avoid a race - // between binding to CMS and onResume() - inForeground = true; - - // Create a GLSurfaceView to fetch GLRenderer unless we have - // a cached result already. - final GlPreferences glPrefs = GlPreferences.readPreferences(this); - if (!glPrefs.savedFingerprint.equals(Build.FINGERPRINT) || glPrefs.glRenderer.isEmpty()) { - GLSurfaceView surfaceView = new GLSurfaceView(this); - surfaceView.setRenderer(new GLSurfaceView.Renderer() { - @Override - public void onSurfaceCreated(GL10 gl10, EGLConfig eglConfig) { - // Save the GLRenderer string so we don't need to do this next time - glPrefs.glRenderer = gl10.glGetString(GL10.GL_RENDERER); - glPrefs.savedFingerprint = Build.FINGERPRINT; - glPrefs.writePreferences(); - - LimeLog.info("Fetched GL Renderer: " + glPrefs.glRenderer); - - runOnUiThread(new Runnable() { - @Override - public void run() { - completeOnCreate(); - } - }); - } - - @Override - public void onSurfaceChanged(GL10 gl10, int i, int i1) { - } - - @Override - public void onDrawFrame(GL10 gl10) { - } - }); - setContentView(surfaceView); - } - else { - LimeLog.info("Cached GL Renderer: " + glPrefs.glRenderer); - completeOnCreate(); - } - } - - private void completeOnCreate() { - completeOnCreateCalled = true; - - shortcutHelper = new ShortcutHelper(this); - - UiHelper.setLocale(this); - - // Bind to the computer manager service - bindService(new Intent(PcView.this, ComputerManagerService.class), serviceConnection, - Service.BIND_AUTO_CREATE); - - pcGridAdapter = new PcGridAdapter(this, PreferenceConfiguration.readPreferences(this)); - - initializeViews(); - } - - private void startComputerUpdates() { - // Only allow polling to start if we're bound to CMS, polling is not already running, - // and our activity is in the foreground. - if (managerBinder != null && !runningPolling && inForeground) { - freezeUpdates = false; - managerBinder.startPolling(new ComputerManagerListener() { - @Override - public void notifyComputerUpdated(final ComputerDetails details) { - if (!freezeUpdates) { - PcView.this.runOnUiThread(new Runnable() { - @Override - public void run() { - updateComputer(details); - } - }); - - // Add a launcher shortcut for this PC (off the main thread to prevent ANRs) - if (details.pairState == PairState.PAIRED) { - shortcutHelper.createAppViewShortcutForOnlineHost(details); - } - } - } - }); - runningPolling = true; - } - } - - private void stopComputerUpdates(boolean wait) { - if (managerBinder != null) { - if (!runningPolling) { - return; - } - - freezeUpdates = true; - - managerBinder.stopPolling(); - - if (wait) { - managerBinder.waitForPollingStopped(); - } - - runningPolling = false; - } - } - - @Override - public void onDestroy() { - super.onDestroy(); - - if (managerBinder != null) { - unbindService(serviceConnection); - } - } - - @Override - protected void onResume() { - super.onResume(); - - // Display a decoder crash notification if we've returned after a crash - UiHelper.showDecoderCrashDialog(this); - - inForeground = true; - startComputerUpdates(); - } - - @Override - protected void onPause() { - super.onPause(); - - inForeground = false; - stopComputerUpdates(false); - } - - @Override - protected void onStop() { - super.onStop(); - - Dialog.closeDialogs(); - } - - @Override - public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) { - stopComputerUpdates(false); - - // Call superclass - super.onCreateContextMenu(menu, v, menuInfo); - - AdapterContextMenuInfo info = (AdapterContextMenuInfo) menuInfo; - ComputerObject computer = (ComputerObject) pcGridAdapter.getItem(info.position); - - // Add a header with PC status details - menu.clearHeader(); - String headerTitle = computer.details.name + " - "; - switch (computer.details.state) - { - case ONLINE: - headerTitle += getResources().getString(R.string.pcview_menu_header_online); - break; - case OFFLINE: - menu.setHeaderIcon(R.drawable.ic_pc_offline); - headerTitle += getResources().getString(R.string.pcview_menu_header_offline); - break; - case UNKNOWN: - headerTitle += getResources().getString(R.string.pcview_menu_header_unknown); - break; - } - - menu.setHeaderTitle(headerTitle); - - // Inflate the context menu - if (computer.details.state == ComputerDetails.State.OFFLINE || - computer.details.state == ComputerDetails.State.UNKNOWN) { - menu.add(Menu.NONE, WOL_ID, 1, getResources().getString(R.string.pcview_menu_send_wol)); - menu.add(Menu.NONE, GAMESTREAM_EOL_ID, 2, getResources().getString(R.string.pcview_menu_eol)); - } - else if (computer.details.pairState != PairState.PAIRED) { - menu.add(Menu.NONE, PAIR_ID, 1, getResources().getString(R.string.pcview_menu_pair_pc)); - if (computer.details.nvidiaServer) { - menu.add(Menu.NONE, GAMESTREAM_EOL_ID, 2, getResources().getString(R.string.pcview_menu_eol)); - } - } - else { - if (computer.details.runningGameId != 0) { - menu.add(Menu.NONE, RESUME_ID, 1, getResources().getString(R.string.applist_menu_resume)); - menu.add(Menu.NONE, QUIT_ID, 2, getResources().getString(R.string.applist_menu_quit)); - } - - if (computer.details.nvidiaServer) { - menu.add(Menu.NONE, GAMESTREAM_EOL_ID, 3, getResources().getString(R.string.pcview_menu_eol)); - } - - menu.add(Menu.NONE, FULL_APP_LIST_ID, 4, getResources().getString(R.string.pcview_menu_app_list)); - } - - menu.add(Menu.NONE, TEST_NETWORK_ID, 5, getResources().getString(R.string.pcview_menu_test_network)); - menu.add(Menu.NONE, DELETE_ID, 6, getResources().getString(R.string.pcview_menu_delete_pc)); - menu.add(Menu.NONE, VIEW_DETAILS_ID, 7, getResources().getString(R.string.pcview_menu_details)); - } - - @Override - public void onContextMenuClosed(Menu menu) { - // For some reason, this gets called again _after_ onPause() is called on this activity. - // startComputerUpdates() manages this and won't actual start polling until the activity - // returns to the foreground. - startComputerUpdates(); - } - - private void doPair(final ComputerDetails computer) { - if (computer.state == ComputerDetails.State.OFFLINE || computer.activeAddress == null) { - Toast.makeText(PcView.this, getResources().getString(R.string.pair_pc_offline), Toast.LENGTH_SHORT).show(); - return; - } - if (managerBinder == null) { - Toast.makeText(PcView.this, getResources().getString(R.string.error_manager_not_running), Toast.LENGTH_LONG).show(); - return; - } - - Toast.makeText(PcView.this, getResources().getString(R.string.pairing), Toast.LENGTH_SHORT).show(); - new Thread(new Runnable() { - @Override - public void run() { - NvHTTP httpConn; - String message; - boolean success = false; - try { - // Stop updates and wait while pairing - stopComputerUpdates(true); - - httpConn = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(computer), - computer.httpsPort, managerBinder.getUniqueId(), computer.serverCert, - PlatformBinding.getCryptoProvider(PcView.this)); - if (httpConn.getPairState() == PairState.PAIRED) { - // Don't display any toast, but open the app list - message = null; - success = true; - } - else { - final String pinStr = PairingManager.generatePinString(); - - // Spin the dialog off in a thread because it blocks - Dialog.displayDialog(PcView.this, getResources().getString(R.string.pair_pairing_title), - getResources().getString(R.string.pair_pairing_msg)+" "+pinStr+"\n\n"+ - getResources().getString(R.string.pair_pairing_help), false); - - PairingManager pm = httpConn.getPairingManager(); - - PairState pairState = pm.pair(httpConn.getServerInfo(true), pinStr); - if (pairState == PairState.PIN_WRONG) { - message = getResources().getString(R.string.pair_incorrect_pin); - } - else if (pairState == PairState.FAILED) { - if (computer.runningGameId != 0) { - message = getResources().getString(R.string.pair_pc_ingame); - } - else { - message = getResources().getString(R.string.pair_fail); - } - } - else if (pairState == PairState.ALREADY_IN_PROGRESS) { - message = getResources().getString(R.string.pair_already_in_progress); - } - else if (pairState == PairState.PAIRED) { - // Just navigate to the app view without displaying a toast - message = null; - success = true; - - // Pin this certificate for later HTTPS use - managerBinder.getComputer(computer.uuid).serverCert = pm.getPairedCert(); - - // Invalidate reachability information after pairing to force - // a refresh before reading pair state again - managerBinder.invalidateStateForComputer(computer.uuid); - } - else { - // Should be no other values - message = null; - } - } - } catch (UnknownHostException e) { - message = getResources().getString(R.string.error_unknown_host); - } catch (FileNotFoundException e) { - message = getResources().getString(R.string.error_404); - } catch (XmlPullParserException | IOException e) { - e.printStackTrace(); - message = e.getMessage(); - } - - Dialog.closeDialogs(); - - final String toastMessage = message; - final boolean toastSuccess = success; - runOnUiThread(new Runnable() { - @Override - public void run() { - if (toastMessage != null) { - Toast.makeText(PcView.this, toastMessage, Toast.LENGTH_LONG).show(); - } - - if (toastSuccess) { - // Open the app list after a successful pairing attempt - doAppList(computer, true, false); - } - else { - // Start polling again if we're still in the foreground - startComputerUpdates(); - } - } - }); - } - }).start(); - } - - private void doWakeOnLan(final ComputerDetails computer) { - if (computer.state == ComputerDetails.State.ONLINE) { - Toast.makeText(PcView.this, getResources().getString(R.string.wol_pc_online), Toast.LENGTH_SHORT).show(); - return; - } - - if (computer.macAddress == null) { - Toast.makeText(PcView.this, getResources().getString(R.string.wol_no_mac), Toast.LENGTH_SHORT).show(); - return; - } - - new Thread(new Runnable() { - @Override - public void run() { - String message; - try { - WakeOnLanSender.sendWolPacket(computer); - message = getResources().getString(R.string.wol_waking_msg); - } catch (IOException e) { - message = getResources().getString(R.string.wol_fail); - } - - final String toastMessage = message; - runOnUiThread(new Runnable() { - @Override - public void run() { - Toast.makeText(PcView.this, toastMessage, Toast.LENGTH_LONG).show(); - } - }); - } - }).start(); - } - - private void doUnpair(final ComputerDetails computer) { - if (computer.state == ComputerDetails.State.OFFLINE || computer.activeAddress == null) { - Toast.makeText(PcView.this, getResources().getString(R.string.error_pc_offline), Toast.LENGTH_SHORT).show(); - return; - } - if (managerBinder == null) { - Toast.makeText(PcView.this, getResources().getString(R.string.error_manager_not_running), Toast.LENGTH_LONG).show(); - return; - } - - Toast.makeText(PcView.this, getResources().getString(R.string.unpairing), Toast.LENGTH_SHORT).show(); - new Thread(new Runnable() { - @Override - public void run() { - NvHTTP httpConn; - String message; - try { - httpConn = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(computer), - computer.httpsPort, managerBinder.getUniqueId(), computer.serverCert, - PlatformBinding.getCryptoProvider(PcView.this)); - if (httpConn.getPairState() == PairingManager.PairState.PAIRED) { - httpConn.unpair(); - if (httpConn.getPairState() == PairingManager.PairState.NOT_PAIRED) { - message = getResources().getString(R.string.unpair_success); - } - else { - message = getResources().getString(R.string.unpair_fail); - } - } - else { - message = getResources().getString(R.string.unpair_error); - } - } catch (UnknownHostException e) { - message = getResources().getString(R.string.error_unknown_host); - } catch (FileNotFoundException e) { - message = getResources().getString(R.string.error_404); - } catch (XmlPullParserException | IOException e) { - message = e.getMessage(); - e.printStackTrace(); - } - - final String toastMessage = message; - runOnUiThread(new Runnable() { - @Override - public void run() { - Toast.makeText(PcView.this, toastMessage, Toast.LENGTH_LONG).show(); - } - }); - } - }).start(); - } - - private void doAppList(ComputerDetails computer, boolean newlyPaired, boolean showHiddenGames) { - if (computer.state == ComputerDetails.State.OFFLINE) { - Toast.makeText(PcView.this, getResources().getString(R.string.error_pc_offline), Toast.LENGTH_SHORT).show(); - return; - } - if (managerBinder == null) { - Toast.makeText(PcView.this, getResources().getString(R.string.error_manager_not_running), Toast.LENGTH_LONG).show(); - return; - } - - Intent i = new Intent(this, AppView.class); - i.putExtra(AppView.NAME_EXTRA, computer.name); - i.putExtra(AppView.UUID_EXTRA, computer.uuid); - i.putExtra(AppView.NEW_PAIR_EXTRA, newlyPaired); - i.putExtra(AppView.SHOW_HIDDEN_APPS_EXTRA, showHiddenGames); - startActivity(i); - } - - @Override - public boolean onContextItemSelected(MenuItem item) { - AdapterContextMenuInfo info = (AdapterContextMenuInfo) item.getMenuInfo(); - final ComputerObject computer = (ComputerObject) pcGridAdapter.getItem(info.position); - switch (item.getItemId()) { - case PAIR_ID: - doPair(computer.details); - return true; - - case UNPAIR_ID: - doUnpair(computer.details); - return true; - - case WOL_ID: - doWakeOnLan(computer.details); - return true; - - case DELETE_ID: - if (ActivityManager.isUserAMonkey()) { - LimeLog.info("Ignoring delete PC request from monkey"); - return true; - } - UiHelper.displayDeletePcConfirmationDialog(this, computer.details, new Runnable() { - @Override - public void run() { - if (managerBinder == null) { - Toast.makeText(PcView.this, getResources().getString(R.string.error_manager_not_running), Toast.LENGTH_LONG).show(); - return; - } - removeComputer(computer.details); - } - }, null); - return true; - - case FULL_APP_LIST_ID: - doAppList(computer.details, false, true); - return true; - - case RESUME_ID: - if (managerBinder == null) { - Toast.makeText(PcView.this, getResources().getString(R.string.error_manager_not_running), Toast.LENGTH_LONG).show(); - return true; - } - - ServerHelper.doStart(this, new NvApp("app", computer.details.runningGameId, false), computer.details, managerBinder); - return true; - - case QUIT_ID: - if (managerBinder == null) { - Toast.makeText(PcView.this, getResources().getString(R.string.error_manager_not_running), Toast.LENGTH_LONG).show(); - return true; - } - - // Display a confirmation dialog first - UiHelper.displayQuitConfirmationDialog(this, new Runnable() { - @Override - public void run() { - ServerHelper.doQuit(PcView.this, computer.details, - new NvApp("app", 0, false), managerBinder, null); - } - }, null); - return true; - - case VIEW_DETAILS_ID: - Dialog.displayDialog(PcView.this, getResources().getString(R.string.title_details), computer.details.toString(), false); - return true; - - case TEST_NETWORK_ID: - ServerHelper.doNetworkTest(PcView.this); - return true; - - case GAMESTREAM_EOL_ID: - HelpLauncher.launchGameStreamEolFaq(PcView.this); - return true; - - default: - return super.onContextItemSelected(item); - } - } - - private void removeComputer(ComputerDetails details) { - managerBinder.removeComputer(details); - - new DiskAssetLoader(this).deleteAssetsForComputer(details.uuid); - - // Delete hidden games preference value - getSharedPreferences(AppView.HIDDEN_APPS_PREF_FILENAME, MODE_PRIVATE) - .edit() - .remove(details.uuid) - .apply(); - - for (int i = 0; i < pcGridAdapter.getCount(); i++) { - ComputerObject computer = (ComputerObject) pcGridAdapter.getItem(i); - - if (details.equals(computer.details)) { - // Disable or delete shortcuts referencing this PC - shortcutHelper.disableComputerShortcut(details, - getResources().getString(R.string.scut_deleted_pc)); - - pcGridAdapter.removeComputer(computer); - pcGridAdapter.notifyDataSetChanged(); - - if (pcGridAdapter.getCount() == 0) { - // Show the "Discovery in progress" view - noPcFoundLayout.setVisibility(View.VISIBLE); - } - - break; - } - } - } - - private void updateComputer(ComputerDetails details) { - ComputerObject existingEntry = null; - - for (int i = 0; i < pcGridAdapter.getCount(); i++) { - ComputerObject computer = (ComputerObject) pcGridAdapter.getItem(i); - - // Check if this is the same computer - if (details.uuid.equals(computer.details.uuid)) { - existingEntry = computer; - break; - } - } - - if (existingEntry != null) { - // Replace the information in the existing entry - existingEntry.details = details; - } - else { - // Add a new entry - pcGridAdapter.addComputer(new ComputerObject(details)); - - // Remove the "Discovery in progress" view - noPcFoundLayout.setVisibility(View.INVISIBLE); - } - - // Notify the view that the data has changed - pcGridAdapter.notifyDataSetChanged(); - } - - @Override - public int getAdapterFragmentLayoutId() { - return R.layout.pc_grid_view; - } - - @Override - public void receiveAbsListView(AbsListView listView) { - listView.setAdapter(pcGridAdapter); - listView.setOnItemClickListener(new OnItemClickListener() { - @Override - public void onItemClick(AdapterView arg0, View arg1, int pos, - long id) { - ComputerObject computer = (ComputerObject) pcGridAdapter.getItem(pos); - if (computer.details.state == ComputerDetails.State.UNKNOWN || - computer.details.state == ComputerDetails.State.OFFLINE) { - // Open the context menu if a PC is offline or refreshing - openContextMenu(arg1); - } else if (computer.details.pairState != PairState.PAIRED) { - // Pair an unpaired machine by default - doPair(computer.details); - } else { - doAppList(computer.details, false, false); - } - } - }); - UiHelper.applyStatusBarPadding(listView); - registerForContextMenu(listView); - } - - public static class ComputerObject { - public ComputerDetails details; - - public ComputerObject(ComputerDetails details) { - if (details == null) { - throw new IllegalArgumentException("details must not be null"); - } - this.details = details; - } - - @Override - public String toString() { - return details.name; - } - } -} +package com.limelight; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.net.UnknownHostException; + +import com.limelight.binding.PlatformBinding; +import com.limelight.binding.crypto.AndroidCryptoProvider; +import com.limelight.computers.ComputerManagerListener; +import com.limelight.computers.ComputerManagerService; +import com.limelight.grid.PcGridAdapter; +import com.limelight.grid.assets.DiskAssetLoader; +import com.limelight.nvstream.http.ComputerDetails; +import com.limelight.nvstream.http.NvApp; +import com.limelight.nvstream.http.NvHTTP; +import com.limelight.nvstream.http.PairingManager; +import com.limelight.nvstream.http.PairingManager.PairState; +import com.limelight.nvstream.wol.WakeOnLanSender; +import com.limelight.preferences.AddComputerManually; +import com.limelight.preferences.GlPreferences; +import com.limelight.preferences.PreferenceConfiguration; +import com.limelight.preferences.StreamSettings; +import com.limelight.ui.AdapterFragment; +import com.limelight.ui.AdapterFragmentCallbacks; +import com.limelight.utils.Dialog; +import com.limelight.utils.HelpLauncher; +import com.limelight.utils.ServerHelper; +import com.limelight.utils.ShortcutHelper; +import com.limelight.utils.UiHelper; + +import android.app.Activity; +import android.app.ActivityManager; +import android.app.Service; +import android.content.ComponentName; +import android.content.Intent; +import android.content.ServiceConnection; +import android.content.res.Configuration; +import android.opengl.GLSurfaceView; +import android.os.Build; +import android.os.Bundle; +import android.os.IBinder; +import android.preference.PreferenceManager; +import android.view.ContextMenu; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ContextMenu.ContextMenuInfo; +import android.view.View.OnClickListener; +import android.widget.AbsListView; +import android.widget.AdapterView; +import android.widget.AdapterView.OnItemClickListener; +import android.widget.ImageButton; +import android.widget.RelativeLayout; +import android.widget.Toast; +import android.widget.AdapterView.AdapterContextMenuInfo; + +import org.xmlpull.v1.XmlPullParserException; + +import javax.microedition.khronos.egl.EGLConfig; +import javax.microedition.khronos.opengles.GL10; + +public class PcView extends Activity implements AdapterFragmentCallbacks { + private RelativeLayout noPcFoundLayout; + private PcGridAdapter pcGridAdapter; + private ShortcutHelper shortcutHelper; + private ComputerManagerService.ComputerManagerBinder managerBinder; + private boolean freezeUpdates, runningPolling, inForeground, completeOnCreateCalled; + private final ServiceConnection serviceConnection = new ServiceConnection() { + public void onServiceConnected(ComponentName className, IBinder binder) { + final ComputerManagerService.ComputerManagerBinder localBinder = + ((ComputerManagerService.ComputerManagerBinder)binder); + + // Wait in a separate thread to avoid stalling the UI + new Thread() { + @Override + public void run() { + // Wait for the binder to be ready + localBinder.waitForReady(); + + // Now make the binder visible + managerBinder = localBinder; + + // Start updates + startComputerUpdates(); + + // Force a keypair to be generated early to avoid discovery delays + new AndroidCryptoProvider(PcView.this).getClientCertificate(); + } + }.start(); + } + + public void onServiceDisconnected(ComponentName className) { + managerBinder = null; + } + }; + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + + // Only reinitialize views if completeOnCreate() was called + // before this callback. If it was not, completeOnCreate() will + // handle initializing views with the config change accounted for. + // This is not prone to races because both callbacks are invoked + // in the main thread. + if (completeOnCreateCalled) { + // Reinitialize views just in case orientation changed + initializeViews(); + } + } + + private final static int PAIR_ID = 2; + private final static int UNPAIR_ID = 3; + private final static int WOL_ID = 4; + private final static int DELETE_ID = 5; + private final static int RESUME_ID = 6; + private final static int QUIT_ID = 7; + private final static int VIEW_DETAILS_ID = 8; + private final static int FULL_APP_LIST_ID = 9; + private final static int TEST_NETWORK_ID = 10; + private final static int GAMESTREAM_EOL_ID = 11; + + private void initializeViews() { + setContentView(R.layout.activity_pc_view); + + UiHelper.notifyNewRootView(this); + + // Allow floating expanded PiP overlays while browsing PCs + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + setShouldDockBigOverlays(false); + } + + // Set default preferences if we've never been run + PreferenceManager.setDefaultValues(this, R.xml.preferences, false); + + // Set the correct layout for the PC grid + pcGridAdapter.updateLayoutWithPreferences(this, PreferenceConfiguration.readPreferences(this)); + + // Setup the list view + ImageButton settingsButton = findViewById(R.id.settingsButton); + ImageButton addComputerButton = findViewById(R.id.manuallyAddPc); + ImageButton helpButton = findViewById(R.id.helpButton); + + settingsButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + startActivity(new Intent(PcView.this, StreamSettings.class)); + } + }); + addComputerButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + Intent i = new Intent(PcView.this, AddComputerManually.class); + startActivity(i); + } + }); + helpButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + HelpLauncher.launchSetupGuide(PcView.this); + } + }); + + // Amazon review didn't like the help button because the wiki was not entirely + // navigable via the Fire TV remote (though the relevant parts were). Let's hide + // it on Fire TV. + if (getPackageManager().hasSystemFeature("amazon.hardware.fire_tv")) { + helpButton.setVisibility(View.GONE); + } + + getFragmentManager().beginTransaction() + .replace(R.id.pcFragmentContainer, new AdapterFragment()) + .commitAllowingStateLoss(); + + noPcFoundLayout = findViewById(R.id.no_pc_found_layout); + if (pcGridAdapter.getCount() == 0) { + noPcFoundLayout.setVisibility(View.VISIBLE); + } + else { + noPcFoundLayout.setVisibility(View.INVISIBLE); + } + pcGridAdapter.notifyDataSetChanged(); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // Assume we're in the foreground when created to avoid a race + // between binding to CMS and onResume() + inForeground = true; + + // Create a GLSurfaceView to fetch GLRenderer unless we have + // a cached result already. + final GlPreferences glPrefs = GlPreferences.readPreferences(this); + if (!glPrefs.savedFingerprint.equals(Build.FINGERPRINT) || glPrefs.glRenderer.isEmpty()) { + GLSurfaceView surfaceView = new GLSurfaceView(this); + surfaceView.setRenderer(new GLSurfaceView.Renderer() { + @Override + public void onSurfaceCreated(GL10 gl10, EGLConfig eglConfig) { + // Save the GLRenderer string so we don't need to do this next time + glPrefs.glRenderer = gl10.glGetString(GL10.GL_RENDERER); + glPrefs.savedFingerprint = Build.FINGERPRINT; + glPrefs.writePreferences(); + + LimeLog.info("Fetched GL Renderer: " + glPrefs.glRenderer); + + runOnUiThread(new Runnable() { + @Override + public void run() { + completeOnCreate(); + } + }); + } + + @Override + public void onSurfaceChanged(GL10 gl10, int i, int i1) { + } + + @Override + public void onDrawFrame(GL10 gl10) { + } + }); + setContentView(surfaceView); + } + else { + LimeLog.info("Cached GL Renderer: " + glPrefs.glRenderer); + completeOnCreate(); + } + } + + private void completeOnCreate() { + completeOnCreateCalled = true; + + shortcutHelper = new ShortcutHelper(this); + + UiHelper.setLocale(this); + + // Bind to the computer manager service + bindService(new Intent(PcView.this, ComputerManagerService.class), serviceConnection, + Service.BIND_AUTO_CREATE); + + pcGridAdapter = new PcGridAdapter(this, PreferenceConfiguration.readPreferences(this)); + + initializeViews(); + } + + private void startComputerUpdates() { + // Only allow polling to start if we're bound to CMS, polling is not already running, + // and our activity is in the foreground. + if (managerBinder != null && !runningPolling && inForeground) { + freezeUpdates = false; + managerBinder.startPolling(new ComputerManagerListener() { + @Override + public void notifyComputerUpdated(final ComputerDetails details) { + if (!freezeUpdates) { + PcView.this.runOnUiThread(new Runnable() { + @Override + public void run() { + updateComputer(details); + } + }); + + // Add a launcher shortcut for this PC (off the main thread to prevent ANRs) + if (details.pairState == PairState.PAIRED) { + shortcutHelper.createAppViewShortcutForOnlineHost(details); + } + } + } + }); + runningPolling = true; + } + } + + private void stopComputerUpdates(boolean wait) { + if (managerBinder != null) { + if (!runningPolling) { + return; + } + + freezeUpdates = true; + + managerBinder.stopPolling(); + + if (wait) { + managerBinder.waitForPollingStopped(); + } + + runningPolling = false; + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + + if (managerBinder != null) { + unbindService(serviceConnection); + } + } + + @Override + protected void onResume() { + super.onResume(); + + // Display a decoder crash notification if we've returned after a crash + UiHelper.showDecoderCrashDialog(this); + + inForeground = true; + startComputerUpdates(); + } + + @Override + protected void onPause() { + super.onPause(); + + inForeground = false; + stopComputerUpdates(false); + } + + @Override + protected void onStop() { + super.onStop(); + + Dialog.closeDialogs(); + } + + @Override + public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) { + stopComputerUpdates(false); + + // Call superclass + super.onCreateContextMenu(menu, v, menuInfo); + + AdapterContextMenuInfo info = (AdapterContextMenuInfo) menuInfo; + ComputerObject computer = (ComputerObject) pcGridAdapter.getItem(info.position); + + // Add a header with PC status details + menu.clearHeader(); + String headerTitle = computer.details.name + " - "; + switch (computer.details.state) + { + case ONLINE: + headerTitle += getResources().getString(R.string.pcview_menu_header_online); + break; + case OFFLINE: + menu.setHeaderIcon(R.drawable.ic_pc_offline); + headerTitle += getResources().getString(R.string.pcview_menu_header_offline); + break; + case UNKNOWN: + headerTitle += getResources().getString(R.string.pcview_menu_header_unknown); + break; + } + + menu.setHeaderTitle(headerTitle); + + // Inflate the context menu + if (computer.details.state == ComputerDetails.State.OFFLINE || + computer.details.state == ComputerDetails.State.UNKNOWN) { + menu.add(Menu.NONE, WOL_ID, 1, getResources().getString(R.string.pcview_menu_send_wol)); + menu.add(Menu.NONE, GAMESTREAM_EOL_ID, 2, getResources().getString(R.string.pcview_menu_eol)); + } + else if (computer.details.pairState != PairState.PAIRED) { + menu.add(Menu.NONE, PAIR_ID, 1, getResources().getString(R.string.pcview_menu_pair_pc)); + if (computer.details.nvidiaServer) { + menu.add(Menu.NONE, GAMESTREAM_EOL_ID, 2, getResources().getString(R.string.pcview_menu_eol)); + } + } + else { + if (computer.details.runningGameId != 0) { + menu.add(Menu.NONE, RESUME_ID, 1, getResources().getString(R.string.applist_menu_resume)); + menu.add(Menu.NONE, QUIT_ID, 2, getResources().getString(R.string.applist_menu_quit)); + } + + if (computer.details.nvidiaServer) { + menu.add(Menu.NONE, GAMESTREAM_EOL_ID, 3, getResources().getString(R.string.pcview_menu_eol)); + } + + menu.add(Menu.NONE, FULL_APP_LIST_ID, 4, getResources().getString(R.string.pcview_menu_app_list)); + } + + menu.add(Menu.NONE, TEST_NETWORK_ID, 5, getResources().getString(R.string.pcview_menu_test_network)); + menu.add(Menu.NONE, DELETE_ID, 6, getResources().getString(R.string.pcview_menu_delete_pc)); + menu.add(Menu.NONE, VIEW_DETAILS_ID, 7, getResources().getString(R.string.pcview_menu_details)); + } + + @Override + public void onContextMenuClosed(Menu menu) { + // For some reason, this gets called again _after_ onPause() is called on this activity. + // startComputerUpdates() manages this and won't actual start polling until the activity + // returns to the foreground. + startComputerUpdates(); + } + + private void doPair(final ComputerDetails computer) { + if (computer.state == ComputerDetails.State.OFFLINE || computer.activeAddress == null) { + Toast.makeText(PcView.this, getResources().getString(R.string.pair_pc_offline), Toast.LENGTH_SHORT).show(); + return; + } + if (managerBinder == null) { + Toast.makeText(PcView.this, getResources().getString(R.string.error_manager_not_running), Toast.LENGTH_LONG).show(); + return; + } + + Toast.makeText(PcView.this, getResources().getString(R.string.pairing), Toast.LENGTH_SHORT).show(); + new Thread(new Runnable() { + @Override + public void run() { + NvHTTP httpConn; + String message; + boolean success = false; + try { + // Stop updates and wait while pairing + stopComputerUpdates(true); + + httpConn = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(computer), + computer.httpsPort, managerBinder.getUniqueId(), computer.serverCert, + PlatformBinding.getCryptoProvider(PcView.this)); + if (httpConn.getPairState() == PairState.PAIRED) { + // Don't display any toast, but open the app list + message = null; + success = true; + } + else { + final String pinStr = PairingManager.generatePinString(); + + // Spin the dialog off in a thread because it blocks + Dialog.displayDialog(PcView.this, getResources().getString(R.string.pair_pairing_title), + getResources().getString(R.string.pair_pairing_msg)+" "+pinStr+"\n\n"+ + getResources().getString(R.string.pair_pairing_help), false); + + PairingManager pm = httpConn.getPairingManager(); + + PairState pairState = pm.pair(httpConn.getServerInfo(true), pinStr); + if (pairState == PairState.PIN_WRONG) { + message = getResources().getString(R.string.pair_incorrect_pin); + } + else if (pairState == PairState.FAILED) { + if (computer.runningGameId != 0) { + message = getResources().getString(R.string.pair_pc_ingame); + } + else { + message = getResources().getString(R.string.pair_fail); + } + } + else if (pairState == PairState.ALREADY_IN_PROGRESS) { + message = getResources().getString(R.string.pair_already_in_progress); + } + else if (pairState == PairState.PAIRED) { + // Just navigate to the app view without displaying a toast + message = null; + success = true; + + // Pin this certificate for later HTTPS use + managerBinder.getComputer(computer.uuid).serverCert = pm.getPairedCert(); + + // Invalidate reachability information after pairing to force + // a refresh before reading pair state again + managerBinder.invalidateStateForComputer(computer.uuid); + } + else { + // Should be no other values + message = null; + } + } + } catch (UnknownHostException e) { + message = getResources().getString(R.string.error_unknown_host); + } catch (FileNotFoundException e) { + message = getResources().getString(R.string.error_404); + } catch (XmlPullParserException | IOException e) { + e.printStackTrace(); + message = e.getMessage(); + } + + Dialog.closeDialogs(); + + final String toastMessage = message; + final boolean toastSuccess = success; + runOnUiThread(new Runnable() { + @Override + public void run() { + if (toastMessage != null) { + Toast.makeText(PcView.this, toastMessage, Toast.LENGTH_LONG).show(); + } + + if (toastSuccess) { + // Open the app list after a successful pairing attempt + doAppList(computer, true, false); + } + else { + // Start polling again if we're still in the foreground + startComputerUpdates(); + } + } + }); + } + }).start(); + } + + private void doWakeOnLan(final ComputerDetails computer) { + if (computer.state == ComputerDetails.State.ONLINE) { + Toast.makeText(PcView.this, getResources().getString(R.string.wol_pc_online), Toast.LENGTH_SHORT).show(); + return; + } + + if (computer.macAddress == null) { + Toast.makeText(PcView.this, getResources().getString(R.string.wol_no_mac), Toast.LENGTH_SHORT).show(); + return; + } + + new Thread(new Runnable() { + @Override + public void run() { + String message; + try { + WakeOnLanSender.sendWolPacket(computer); + message = getResources().getString(R.string.wol_waking_msg); + } catch (IOException e) { + message = getResources().getString(R.string.wol_fail); + } + + final String toastMessage = message; + runOnUiThread(new Runnable() { + @Override + public void run() { + Toast.makeText(PcView.this, toastMessage, Toast.LENGTH_LONG).show(); + } + }); + } + }).start(); + } + + private void doUnpair(final ComputerDetails computer) { + if (computer.state == ComputerDetails.State.OFFLINE || computer.activeAddress == null) { + Toast.makeText(PcView.this, getResources().getString(R.string.error_pc_offline), Toast.LENGTH_SHORT).show(); + return; + } + if (managerBinder == null) { + Toast.makeText(PcView.this, getResources().getString(R.string.error_manager_not_running), Toast.LENGTH_LONG).show(); + return; + } + + Toast.makeText(PcView.this, getResources().getString(R.string.unpairing), Toast.LENGTH_SHORT).show(); + new Thread(new Runnable() { + @Override + public void run() { + NvHTTP httpConn; + String message; + try { + httpConn = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(computer), + computer.httpsPort, managerBinder.getUniqueId(), computer.serverCert, + PlatformBinding.getCryptoProvider(PcView.this)); + if (httpConn.getPairState() == PairingManager.PairState.PAIRED) { + httpConn.unpair(); + if (httpConn.getPairState() == PairingManager.PairState.NOT_PAIRED) { + message = getResources().getString(R.string.unpair_success); + } + else { + message = getResources().getString(R.string.unpair_fail); + } + } + else { + message = getResources().getString(R.string.unpair_error); + } + } catch (UnknownHostException e) { + message = getResources().getString(R.string.error_unknown_host); + } catch (FileNotFoundException e) { + message = getResources().getString(R.string.error_404); + } catch (XmlPullParserException | IOException e) { + message = e.getMessage(); + e.printStackTrace(); + } + + final String toastMessage = message; + runOnUiThread(new Runnable() { + @Override + public void run() { + Toast.makeText(PcView.this, toastMessage, Toast.LENGTH_LONG).show(); + } + }); + } + }).start(); + } + + private void doAppList(ComputerDetails computer, boolean newlyPaired, boolean showHiddenGames) { + if (computer.state == ComputerDetails.State.OFFLINE) { + Toast.makeText(PcView.this, getResources().getString(R.string.error_pc_offline), Toast.LENGTH_SHORT).show(); + return; + } + if (managerBinder == null) { + Toast.makeText(PcView.this, getResources().getString(R.string.error_manager_not_running), Toast.LENGTH_LONG).show(); + return; + } + + Intent i = new Intent(this, AppView.class); + i.putExtra(AppView.NAME_EXTRA, computer.name); + i.putExtra(AppView.UUID_EXTRA, computer.uuid); + i.putExtra(AppView.NEW_PAIR_EXTRA, newlyPaired); + i.putExtra(AppView.SHOW_HIDDEN_APPS_EXTRA, showHiddenGames); + startActivity(i); + } + + @Override + public boolean onContextItemSelected(MenuItem item) { + AdapterContextMenuInfo info = (AdapterContextMenuInfo) item.getMenuInfo(); + final ComputerObject computer = (ComputerObject) pcGridAdapter.getItem(info.position); + switch (item.getItemId()) { + case PAIR_ID: + doPair(computer.details); + return true; + + case UNPAIR_ID: + doUnpair(computer.details); + return true; + + case WOL_ID: + doWakeOnLan(computer.details); + return true; + + case DELETE_ID: + if (ActivityManager.isUserAMonkey()) { + LimeLog.info("Ignoring delete PC request from monkey"); + return true; + } + UiHelper.displayDeletePcConfirmationDialog(this, computer.details, new Runnable() { + @Override + public void run() { + if (managerBinder == null) { + Toast.makeText(PcView.this, getResources().getString(R.string.error_manager_not_running), Toast.LENGTH_LONG).show(); + return; + } + removeComputer(computer.details); + } + }, null); + return true; + + case FULL_APP_LIST_ID: + doAppList(computer.details, false, true); + return true; + + case RESUME_ID: + if (managerBinder == null) { + Toast.makeText(PcView.this, getResources().getString(R.string.error_manager_not_running), Toast.LENGTH_LONG).show(); + return true; + } + + ServerHelper.doStart(this, new NvApp("app", computer.details.runningGameId, false), computer.details, managerBinder); + return true; + + case QUIT_ID: + if (managerBinder == null) { + Toast.makeText(PcView.this, getResources().getString(R.string.error_manager_not_running), Toast.LENGTH_LONG).show(); + return true; + } + + // Display a confirmation dialog first + UiHelper.displayQuitConfirmationDialog(this, new Runnable() { + @Override + public void run() { + ServerHelper.doQuit(PcView.this, computer.details, + new NvApp("app", 0, false), managerBinder, null); + } + }, null); + return true; + + case VIEW_DETAILS_ID: + Dialog.displayDialog(PcView.this, getResources().getString(R.string.title_details), computer.details.toString(), false); + return true; + + case TEST_NETWORK_ID: + ServerHelper.doNetworkTest(PcView.this); + return true; + + case GAMESTREAM_EOL_ID: + HelpLauncher.launchGameStreamEolFaq(PcView.this); + return true; + + default: + return super.onContextItemSelected(item); + } + } + + private void removeComputer(ComputerDetails details) { + managerBinder.removeComputer(details); + + new DiskAssetLoader(this).deleteAssetsForComputer(details.uuid); + + // Delete hidden games preference value + getSharedPreferences(AppView.HIDDEN_APPS_PREF_FILENAME, MODE_PRIVATE) + .edit() + .remove(details.uuid) + .apply(); + + for (int i = 0; i < pcGridAdapter.getCount(); i++) { + ComputerObject computer = (ComputerObject) pcGridAdapter.getItem(i); + + if (details.equals(computer.details)) { + // Disable or delete shortcuts referencing this PC + shortcutHelper.disableComputerShortcut(details, + getResources().getString(R.string.scut_deleted_pc)); + + pcGridAdapter.removeComputer(computer); + pcGridAdapter.notifyDataSetChanged(); + + if (pcGridAdapter.getCount() == 0) { + // Show the "Discovery in progress" view + noPcFoundLayout.setVisibility(View.VISIBLE); + } + + break; + } + } + } + + private void updateComputer(ComputerDetails details) { + ComputerObject existingEntry = null; + + for (int i = 0; i < pcGridAdapter.getCount(); i++) { + ComputerObject computer = (ComputerObject) pcGridAdapter.getItem(i); + + // Check if this is the same computer + if (details.uuid.equals(computer.details.uuid)) { + existingEntry = computer; + break; + } + } + + if (existingEntry != null) { + // Replace the information in the existing entry + existingEntry.details = details; + } + else { + // Add a new entry + pcGridAdapter.addComputer(new ComputerObject(details)); + + // Remove the "Discovery in progress" view + noPcFoundLayout.setVisibility(View.INVISIBLE); + } + + // Notify the view that the data has changed + pcGridAdapter.notifyDataSetChanged(); + } + + @Override + public int getAdapterFragmentLayoutId() { + return R.layout.pc_grid_view; + } + + @Override + public void receiveAbsListView(AbsListView listView) { + listView.setAdapter(pcGridAdapter); + listView.setOnItemClickListener(new OnItemClickListener() { + @Override + public void onItemClick(AdapterView arg0, View arg1, int pos, + long id) { + ComputerObject computer = (ComputerObject) pcGridAdapter.getItem(pos); + if (computer.details.state == ComputerDetails.State.UNKNOWN || + computer.details.state == ComputerDetails.State.OFFLINE) { + // Open the context menu if a PC is offline or refreshing + openContextMenu(arg1); + } else if (computer.details.pairState != PairState.PAIRED) { + // Pair an unpaired machine by default + doPair(computer.details); + } else { + doAppList(computer.details, false, false); + } + } + }); + UiHelper.applyStatusBarPadding(listView); + registerForContextMenu(listView); + } + + public static class ComputerObject { + public ComputerDetails details; + + public ComputerObject(ComputerDetails details) { + if (details == null) { + throw new IllegalArgumentException("details must not be null"); + } + this.details = details; + } + + @Override + public String toString() { + return details.name; + } + } +} diff --git a/app/src/main/java/com/limelight/PosterContentProvider.java b/app/src/main/java/com/limelight/PosterContentProvider.java old mode 100644 new mode 100755 index 0b45608460..f049e0bfc0 --- a/app/src/main/java/com/limelight/PosterContentProvider.java +++ b/app/src/main/java/com/limelight/PosterContentProvider.java @@ -1,107 +1,107 @@ -package com.limelight; - -import android.content.ContentProvider; -import android.content.ContentResolver; -import android.content.ContentValues; -import android.content.UriMatcher; -import android.database.Cursor; -import android.net.Uri; -import android.os.ParcelFileDescriptor; - -import com.limelight.grid.assets.DiskAssetLoader; - -import java.io.File; -import java.io.FileNotFoundException; -import java.util.List; - -public class PosterContentProvider extends ContentProvider { - - - public static final String AUTHORITY = "poster." + BuildConfig.APPLICATION_ID; - public static final String PNG_MIME_TYPE = "image/png"; - public static final int APP_ID_PATH_INDEX = 2; - public static final int COMPUTER_UUID_PATH_INDEX = 1; - private DiskAssetLoader mDiskAssetLoader; - - private static final UriMatcher sUriMatcher; - private static final String BOXART_PATH = "boxart"; - private static final int BOXART_URI_ID = 1; - - static { - sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH); - sUriMatcher.addURI(AUTHORITY, BOXART_PATH, BOXART_URI_ID); - } - - @Override - public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException { - int match = sUriMatcher.match(uri); - if (match == BOXART_URI_ID) { - return openBoxArtFile(uri, mode); - } - return openBoxArtFile(uri, mode); - - } - - public ParcelFileDescriptor openBoxArtFile(Uri uri, String mode) throws FileNotFoundException { - if (!"r".equals(mode)) { - throw new UnsupportedOperationException("This provider is only for read mode"); - } - - List segments = uri.getPathSegments(); - if (segments.size() != 3) { - throw new FileNotFoundException(); - } - String appId = segments.get(APP_ID_PATH_INDEX); - String uuid = segments.get(COMPUTER_UUID_PATH_INDEX); - File file = mDiskAssetLoader.getFile(uuid, Integer.parseInt(appId)); - if (file.exists()) { - return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY); - } - throw new FileNotFoundException(); - } - - @Override - public int delete(Uri uri, String selection, String[] selectionArgs) { - throw new UnsupportedOperationException("This provider is only for read mode"); - } - - @Override - public String getType(Uri uri) { - return PNG_MIME_TYPE; - } - - @Override - public Uri insert(Uri uri, ContentValues values) { - throw new UnsupportedOperationException("This provider is only for read mode"); - } - - @Override - public boolean onCreate() { - mDiskAssetLoader = new DiskAssetLoader(getContext()); - return true; - } - - @Override - public Cursor query(Uri uri, String[] projection, String selection, - String[] selectionArgs, String sortOrder) { - throw new UnsupportedOperationException("This provider doesn't support query"); - } - - @Override - public int update(Uri uri, ContentValues values, String selection, - String[] selectionArgs) { - throw new UnsupportedOperationException("This provider is support read only"); - } - - - public static Uri createBoxArtUri(String uuid, String appId) { - return new Uri.Builder() - .scheme(ContentResolver.SCHEME_CONTENT) - .authority(AUTHORITY) - .appendPath(BOXART_PATH) - .appendPath(uuid) - .appendPath(appId) - .build(); - } - -} +package com.limelight; + +import android.content.ContentProvider; +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.UriMatcher; +import android.database.Cursor; +import android.net.Uri; +import android.os.ParcelFileDescriptor; + +import com.limelight.grid.assets.DiskAssetLoader; + +import java.io.File; +import java.io.FileNotFoundException; +import java.util.List; + +public class PosterContentProvider extends ContentProvider { + + + public static final String AUTHORITY = "poster." + BuildConfig.APPLICATION_ID; + public static final String PNG_MIME_TYPE = "image/png"; + public static final int APP_ID_PATH_INDEX = 2; + public static final int COMPUTER_UUID_PATH_INDEX = 1; + private DiskAssetLoader mDiskAssetLoader; + + private static final UriMatcher sUriMatcher; + private static final String BOXART_PATH = "boxart"; + private static final int BOXART_URI_ID = 1; + + static { + sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH); + sUriMatcher.addURI(AUTHORITY, BOXART_PATH, BOXART_URI_ID); + } + + @Override + public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException { + int match = sUriMatcher.match(uri); + if (match == BOXART_URI_ID) { + return openBoxArtFile(uri, mode); + } + return openBoxArtFile(uri, mode); + + } + + public ParcelFileDescriptor openBoxArtFile(Uri uri, String mode) throws FileNotFoundException { + if (!"r".equals(mode)) { + throw new UnsupportedOperationException("This provider is only for read mode"); + } + + List segments = uri.getPathSegments(); + if (segments.size() != 3) { + throw new FileNotFoundException(); + } + String appId = segments.get(APP_ID_PATH_INDEX); + String uuid = segments.get(COMPUTER_UUID_PATH_INDEX); + File file = mDiskAssetLoader.getFile(uuid, Integer.parseInt(appId)); + if (file.exists()) { + return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY); + } + throw new FileNotFoundException(); + } + + @Override + public int delete(Uri uri, String selection, String[] selectionArgs) { + throw new UnsupportedOperationException("This provider is only for read mode"); + } + + @Override + public String getType(Uri uri) { + return PNG_MIME_TYPE; + } + + @Override + public Uri insert(Uri uri, ContentValues values) { + throw new UnsupportedOperationException("This provider is only for read mode"); + } + + @Override + public boolean onCreate() { + mDiskAssetLoader = new DiskAssetLoader(getContext()); + return true; + } + + @Override + public Cursor query(Uri uri, String[] projection, String selection, + String[] selectionArgs, String sortOrder) { + throw new UnsupportedOperationException("This provider doesn't support query"); + } + + @Override + public int update(Uri uri, ContentValues values, String selection, + String[] selectionArgs) { + throw new UnsupportedOperationException("This provider is support read only"); + } + + + public static Uri createBoxArtUri(String uuid, String appId) { + return new Uri.Builder() + .scheme(ContentResolver.SCHEME_CONTENT) + .authority(AUTHORITY) + .appendPath(BOXART_PATH) + .appendPath(uuid) + .appendPath(appId) + .build(); + } + +} diff --git a/app/src/main/java/com/limelight/SecondaryDisplayPresentation.java b/app/src/main/java/com/limelight/SecondaryDisplayPresentation.java new file mode 100755 index 0000000000..296f76ff1e --- /dev/null +++ b/app/src/main/java/com/limelight/SecondaryDisplayPresentation.java @@ -0,0 +1,45 @@ +package com.limelight; + +import android.app.Presentation; +import android.content.Context; +import android.os.Bundle; +import android.view.Display; +import android.view.View; +import android.widget.FrameLayout; + +import com.limelight.ui.StreamView; + +/** + * Description + * Date: 2024-03-29 + * Time: 17:26 + */ +public class SecondaryDisplayPresentation extends Presentation { + + private FrameLayout view; + public SecondaryDisplayPresentation(Context context, Display display) { + super(context, display); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + view= (FrameLayout) View.inflate(getContext(),R.layout.activity_game_display,null); + setContentView(view); + } + + public void addView(StreamView streamView){ + view.addView(streamView); + } + + @Override + protected void onStart() { + super.onStart(); + } + + @Override + protected void onStop() { + super.onStop(); + view.removeAllViews(); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/limelight/SensitivityBean.java b/app/src/main/java/com/limelight/SensitivityBean.java new file mode 100755 index 0000000000..cae3760580 --- /dev/null +++ b/app/src/main/java/com/limelight/SensitivityBean.java @@ -0,0 +1,48 @@ +package com.limelight; + +/** + * Description + * Date: 2024-05-10 + * Time: 23:35 + */ +public class SensitivityBean { + //真实的坐标 + private float lastAbsoluteX =-1; + private float lastAbsoluteY =-1; + + //调整灵敏度后的坐标 + private float lastRelativelyX =-1; + private float lastRelativelyY =-1; + + public float getLastAbsoluteX() { + return lastAbsoluteX; + } + + public void setLastAbsoluteX(float lastAbsoluteX) { + this.lastAbsoluteX = lastAbsoluteX; + } + + public float getLastAbsoluteY() { + return lastAbsoluteY; + } + + public void setLastAbsoluteY(float lastAbsoluteY) { + this.lastAbsoluteY = lastAbsoluteY; + } + + public float getLastRelativelyX() { + return lastRelativelyX; + } + + public void setLastRelativelyX(float lastRelativelyX) { + this.lastRelativelyX = lastRelativelyX; + } + + public float getLastRelativelyY() { + return lastRelativelyY; + } + + public void setLastRelativelyY(float lastRelativelyY) { + this.lastRelativelyY = lastRelativelyY; + } +} diff --git a/app/src/main/java/com/limelight/ShortcutTrampoline.java b/app/src/main/java/com/limelight/ShortcutTrampoline.java old mode 100644 new mode 100755 index 777647b68a..60e145c7f0 --- a/app/src/main/java/com/limelight/ShortcutTrampoline.java +++ b/app/src/main/java/com/limelight/ShortcutTrampoline.java @@ -1,297 +1,297 @@ -package com.limelight; - -import android.app.Activity; -import android.app.Service; -import android.content.ComponentName; -import android.content.Intent; -import android.content.ServiceConnection; -import android.os.Bundle; -import android.os.IBinder; - -import com.limelight.computers.ComputerManagerListener; -import com.limelight.computers.ComputerManagerService; -import com.limelight.nvstream.http.ComputerDetails; -import com.limelight.nvstream.http.NvApp; -import com.limelight.nvstream.http.PairingManager; -import com.limelight.nvstream.wol.WakeOnLanSender; -import com.limelight.utils.Dialog; -import com.limelight.utils.ServerHelper; -import com.limelight.utils.SpinnerDialog; -import com.limelight.utils.UiHelper; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.UUID; - -public class ShortcutTrampoline extends Activity { - private String uuidString; - private NvApp app; - private ArrayList intentStack = new ArrayList<>(); - - private int wakeHostTries = 10; - private ComputerDetails computer; - private SpinnerDialog blockingLoadSpinner; - - private ComputerManagerService.ComputerManagerBinder managerBinder; - private final ServiceConnection serviceConnection = new ServiceConnection() { - public void onServiceConnected(ComponentName className, IBinder binder) { - final ComputerManagerService.ComputerManagerBinder localBinder = - ((ComputerManagerService.ComputerManagerBinder)binder); - - // Wait in a separate thread to avoid stalling the UI - new Thread() { - @Override - public void run() { - // Wait for the binder to be ready - localBinder.waitForReady(); - - // Now make the binder visible - managerBinder = localBinder; - - // Get the computer object - computer = managerBinder.getComputer(uuidString); - - if (computer == null) { - Dialog.displayDialog(ShortcutTrampoline.this, - getResources().getString(R.string.conn_error_title), - getResources().getString(R.string.scut_pc_not_found), - true); - - if (blockingLoadSpinner != null) { - blockingLoadSpinner.dismiss(); - blockingLoadSpinner = null; - } - - if (managerBinder != null) { - unbindService(serviceConnection); - managerBinder = null; - } - - return; - } - - // Force CMS to repoll this machine - managerBinder.invalidateStateForComputer(computer.uuid); - - // Start polling - managerBinder.startPolling(new ComputerManagerListener() { - @Override - public void notifyComputerUpdated(final ComputerDetails details) { - // Don't care about other computers - if (!details.uuid.equalsIgnoreCase(uuidString)) { - return; - } - - // Try to wake the target PC if it's offline (up to some retry limit) - if (details.state == ComputerDetails.State.OFFLINE && details.macAddress != null && --wakeHostTries >= 0) { - try { - // Make a best effort attempt to wake the target PC - WakeOnLanSender.sendWolPacket(computer); - - // If we sent at least one WoL packet, reset the computer state - // to force ComputerManager to poll it again. - managerBinder.invalidateStateForComputer(computer.uuid); - return; - } catch (IOException e) { - // If we got an exception, we couldn't send a single WoL packet, - // so fallthrough into the offline error path. - e.printStackTrace(); - } - } - - if (details.state != ComputerDetails.State.UNKNOWN) { - runOnUiThread(new Runnable() { - @Override - public void run() { - // Stop showing the spinner - if (blockingLoadSpinner != null) { - blockingLoadSpinner.dismiss(); - blockingLoadSpinner = null; - } - - // If the managerBinder was destroyed before this callback, - // just finish the activity. - if (managerBinder == null) { - finish(); - return; - } - - if (details.state == ComputerDetails.State.ONLINE && details.pairState == PairingManager.PairState.PAIRED) { - - // Launch game if provided app ID, otherwise launch app view - if (app != null) { - if (details.runningGameId == 0 || details.runningGameId == app.getAppId()) { - intentStack.add(ServerHelper.createStartIntent(ShortcutTrampoline.this, app, details, managerBinder)); - - // Close this activity - finish(); - - // Now start the activities - startActivities(intentStack.toArray(new Intent[]{})); - } else { - // Create the start intent immediately, so we can safely unbind the managerBinder - // below before we return. - final Intent startIntent = ServerHelper.createStartIntent(ShortcutTrampoline.this, app, details, managerBinder); - - UiHelper.displayQuitConfirmationDialog(ShortcutTrampoline.this, new Runnable() { - @Override - public void run() { - intentStack.add(startIntent); - - // Close this activity - finish(); - - // Now start the activities - startActivities(intentStack.toArray(new Intent[]{})); - } - }, new Runnable() { - @Override - public void run() { - // Close this activity - finish(); - } - }); - } - } else { - // Close this activity - finish(); - - // Add the PC view at the back (and clear the task) - Intent i; - i = new Intent(ShortcutTrampoline.this, PcView.class); - i.setAction(Intent.ACTION_MAIN); - i.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_NEW_TASK); - intentStack.add(i); - - // Take this intent's data and create an intent to start the app view - i = new Intent(getIntent()); - i.setClass(ShortcutTrampoline.this, AppView.class); - intentStack.add(i); - - // If a game is running, we'll make the stream the top level activity - if (details.runningGameId != 0) { - intentStack.add(ServerHelper.createStartIntent(ShortcutTrampoline.this, - new NvApp(null, details.runningGameId, false), details, managerBinder)); - } - - // Now start the activities - startActivities(intentStack.toArray(new Intent[]{})); - } - - } - else if (details.state == ComputerDetails.State.OFFLINE) { - // Computer offline - display an error dialog - Dialog.displayDialog(ShortcutTrampoline.this, - getResources().getString(R.string.conn_error_title), - getResources().getString(R.string.error_pc_offline), - true); - } else if (details.pairState != PairingManager.PairState.PAIRED) { - // Computer not paired - display an error dialog - Dialog.displayDialog(ShortcutTrampoline.this, - getResources().getString(R.string.conn_error_title), - getResources().getString(R.string.scut_not_paired), - true); - } - - // We don't want any more callbacks from now on, so go ahead - // and unbind from the service - if (managerBinder != null) { - managerBinder.stopPolling(); - unbindService(serviceConnection); - managerBinder = null; - } - } - }); - } - } - }); - } - }.start(); - } - - public void onServiceDisconnected(ComponentName className) { - managerBinder = null; - } - }; - - protected boolean validateInput(String uuidString, String appIdString) { - // Validate UUID - if (uuidString == null) { - Dialog.displayDialog(ShortcutTrampoline.this, - getResources().getString(R.string.conn_error_title), - getResources().getString(R.string.scut_invalid_uuid), - true); - return false; - } - - try { - UUID.fromString(uuidString); - } catch (IllegalArgumentException ex) { - Dialog.displayDialog(ShortcutTrampoline.this, - getResources().getString(R.string.conn_error_title), - getResources().getString(R.string.scut_invalid_uuid), - true); - return false; - } - - // Validate App ID (if provided) - if (appIdString != null && !appIdString.isEmpty()) { - try { - Integer.parseInt(appIdString); - } catch (NumberFormatException ex) { - Dialog.displayDialog(ShortcutTrampoline.this, - getResources().getString(R.string.conn_error_title), - getResources().getString(R.string.scut_invalid_app_id), - true); - return false; - } - } - - return true; - } - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - UiHelper.notifyNewRootView(this); - - String appIdString = getIntent().getStringExtra(Game.EXTRA_APP_ID); - uuidString = getIntent().getStringExtra(AppView.UUID_EXTRA); - - if (validateInput(uuidString, appIdString)) { - if (appIdString != null && !appIdString.isEmpty()) { - app = new NvApp(getIntent().getStringExtra(Game.EXTRA_APP_NAME), - Integer.parseInt(appIdString), - getIntent().getBooleanExtra(Game.EXTRA_APP_HDR, false)); - } - - // Bind to the computer manager service - bindService(new Intent(this, ComputerManagerService.class), serviceConnection, - Service.BIND_AUTO_CREATE); - - blockingLoadSpinner = SpinnerDialog.displayDialog(this, getResources().getString(R.string.conn_establishing_title), - getResources().getString(R.string.applist_connect_msg), true); - } - } - - @Override - protected void onStop() { - super.onStop(); - - if (blockingLoadSpinner != null) { - blockingLoadSpinner.dismiss(); - blockingLoadSpinner = null; - } - - Dialog.closeDialogs(); - - if (managerBinder != null) { - managerBinder.stopPolling(); - unbindService(serviceConnection); - managerBinder = null; - } - - finish(); - } -} +package com.limelight; + +import android.app.Activity; +import android.app.Service; +import android.content.ComponentName; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.Bundle; +import android.os.IBinder; + +import com.limelight.computers.ComputerManagerListener; +import com.limelight.computers.ComputerManagerService; +import com.limelight.nvstream.http.ComputerDetails; +import com.limelight.nvstream.http.NvApp; +import com.limelight.nvstream.http.PairingManager; +import com.limelight.nvstream.wol.WakeOnLanSender; +import com.limelight.utils.Dialog; +import com.limelight.utils.ServerHelper; +import com.limelight.utils.SpinnerDialog; +import com.limelight.utils.UiHelper; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.UUID; + +public class ShortcutTrampoline extends Activity { + private String uuidString; + private NvApp app; + private ArrayList intentStack = new ArrayList<>(); + + private int wakeHostTries = 10; + private ComputerDetails computer; + private SpinnerDialog blockingLoadSpinner; + + private ComputerManagerService.ComputerManagerBinder managerBinder; + private final ServiceConnection serviceConnection = new ServiceConnection() { + public void onServiceConnected(ComponentName className, IBinder binder) { + final ComputerManagerService.ComputerManagerBinder localBinder = + ((ComputerManagerService.ComputerManagerBinder)binder); + + // Wait in a separate thread to avoid stalling the UI + new Thread() { + @Override + public void run() { + // Wait for the binder to be ready + localBinder.waitForReady(); + + // Now make the binder visible + managerBinder = localBinder; + + // Get the computer object + computer = managerBinder.getComputer(uuidString); + + if (computer == null) { + Dialog.displayDialog(ShortcutTrampoline.this, + getResources().getString(R.string.conn_error_title), + getResources().getString(R.string.scut_pc_not_found), + true); + + if (blockingLoadSpinner != null) { + blockingLoadSpinner.dismiss(); + blockingLoadSpinner = null; + } + + if (managerBinder != null) { + unbindService(serviceConnection); + managerBinder = null; + } + + return; + } + + // Force CMS to repoll this machine + managerBinder.invalidateStateForComputer(computer.uuid); + + // Start polling + managerBinder.startPolling(new ComputerManagerListener() { + @Override + public void notifyComputerUpdated(final ComputerDetails details) { + // Don't care about other computers + if (!details.uuid.equalsIgnoreCase(uuidString)) { + return; + } + + // Try to wake the target PC if it's offline (up to some retry limit) + if (details.state == ComputerDetails.State.OFFLINE && details.macAddress != null && --wakeHostTries >= 0) { + try { + // Make a best effort attempt to wake the target PC + WakeOnLanSender.sendWolPacket(computer); + + // If we sent at least one WoL packet, reset the computer state + // to force ComputerManager to poll it again. + managerBinder.invalidateStateForComputer(computer.uuid); + return; + } catch (IOException e) { + // If we got an exception, we couldn't send a single WoL packet, + // so fallthrough into the offline error path. + e.printStackTrace(); + } + } + + if (details.state != ComputerDetails.State.UNKNOWN) { + runOnUiThread(new Runnable() { + @Override + public void run() { + // Stop showing the spinner + if (blockingLoadSpinner != null) { + blockingLoadSpinner.dismiss(); + blockingLoadSpinner = null; + } + + // If the managerBinder was destroyed before this callback, + // just finish the activity. + if (managerBinder == null) { + finish(); + return; + } + + if (details.state == ComputerDetails.State.ONLINE && details.pairState == PairingManager.PairState.PAIRED) { + + // Launch game if provided app ID, otherwise launch app view + if (app != null) { + if (details.runningGameId == 0 || details.runningGameId == app.getAppId()) { + intentStack.add(ServerHelper.createStartIntent(ShortcutTrampoline.this, app, details, managerBinder)); + + // Close this activity + finish(); + + // Now start the activities + startActivities(intentStack.toArray(new Intent[]{})); + } else { + // Create the start intent immediately, so we can safely unbind the managerBinder + // below before we return. + final Intent startIntent = ServerHelper.createStartIntent(ShortcutTrampoline.this, app, details, managerBinder); + + UiHelper.displayQuitConfirmationDialog(ShortcutTrampoline.this, new Runnable() { + @Override + public void run() { + intentStack.add(startIntent); + + // Close this activity + finish(); + + // Now start the activities + startActivities(intentStack.toArray(new Intent[]{})); + } + }, new Runnable() { + @Override + public void run() { + // Close this activity + finish(); + } + }); + } + } else { + // Close this activity + finish(); + + // Add the PC view at the back (and clear the task) + Intent i; + i = new Intent(ShortcutTrampoline.this, PcView.class); + i.setAction(Intent.ACTION_MAIN); + i.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_NEW_TASK); + intentStack.add(i); + + // Take this intent's data and create an intent to start the app view + i = new Intent(getIntent()); + i.setClass(ShortcutTrampoline.this, AppView.class); + intentStack.add(i); + + // If a game is running, we'll make the stream the top level activity + if (details.runningGameId != 0) { + intentStack.add(ServerHelper.createStartIntent(ShortcutTrampoline.this, + new NvApp(null, details.runningGameId, false), details, managerBinder)); + } + + // Now start the activities + startActivities(intentStack.toArray(new Intent[]{})); + } + + } + else if (details.state == ComputerDetails.State.OFFLINE) { + // Computer offline - display an error dialog + Dialog.displayDialog(ShortcutTrampoline.this, + getResources().getString(R.string.conn_error_title), + getResources().getString(R.string.error_pc_offline), + true); + } else if (details.pairState != PairingManager.PairState.PAIRED) { + // Computer not paired - display an error dialog + Dialog.displayDialog(ShortcutTrampoline.this, + getResources().getString(R.string.conn_error_title), + getResources().getString(R.string.scut_not_paired), + true); + } + + // We don't want any more callbacks from now on, so go ahead + // and unbind from the service + if (managerBinder != null) { + managerBinder.stopPolling(); + unbindService(serviceConnection); + managerBinder = null; + } + } + }); + } + } + }); + } + }.start(); + } + + public void onServiceDisconnected(ComponentName className) { + managerBinder = null; + } + }; + + protected boolean validateInput(String uuidString, String appIdString) { + // Validate UUID + if (uuidString == null) { + Dialog.displayDialog(ShortcutTrampoline.this, + getResources().getString(R.string.conn_error_title), + getResources().getString(R.string.scut_invalid_uuid), + true); + return false; + } + + try { + UUID.fromString(uuidString); + } catch (IllegalArgumentException ex) { + Dialog.displayDialog(ShortcutTrampoline.this, + getResources().getString(R.string.conn_error_title), + getResources().getString(R.string.scut_invalid_uuid), + true); + return false; + } + + // Validate App ID (if provided) + if (appIdString != null && !appIdString.isEmpty()) { + try { + Integer.parseInt(appIdString); + } catch (NumberFormatException ex) { + Dialog.displayDialog(ShortcutTrampoline.this, + getResources().getString(R.string.conn_error_title), + getResources().getString(R.string.scut_invalid_app_id), + true); + return false; + } + } + + return true; + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + UiHelper.notifyNewRootView(this); + + String appIdString = getIntent().getStringExtra(Game.EXTRA_APP_ID); + uuidString = getIntent().getStringExtra(AppView.UUID_EXTRA); + + if (validateInput(uuidString, appIdString)) { + if (appIdString != null && !appIdString.isEmpty()) { + app = new NvApp(getIntent().getStringExtra(Game.EXTRA_APP_NAME), + Integer.parseInt(appIdString), + getIntent().getBooleanExtra(Game.EXTRA_APP_HDR, false)); + } + + // Bind to the computer manager service + bindService(new Intent(this, ComputerManagerService.class), serviceConnection, + Service.BIND_AUTO_CREATE); + + blockingLoadSpinner = SpinnerDialog.displayDialog(this, getResources().getString(R.string.conn_establishing_title), + getResources().getString(R.string.applist_connect_msg), true); + } + } + + @Override + protected void onStop() { + super.onStop(); + + if (blockingLoadSpinner != null) { + blockingLoadSpinner.dismiss(); + blockingLoadSpinner = null; + } + + Dialog.closeDialogs(); + + if (managerBinder != null) { + managerBinder.stopPolling(); + unbindService(serviceConnection); + managerBinder = null; + } + + finish(); + } +} diff --git a/app/src/main/java/com/limelight/binding/PlatformBinding.java b/app/src/main/java/com/limelight/binding/PlatformBinding.java old mode 100644 new mode 100755 index 73bb2a308e..f7092b08f6 --- a/app/src/main/java/com/limelight/binding/PlatformBinding.java +++ b/app/src/main/java/com/limelight/binding/PlatformBinding.java @@ -1,14 +1,14 @@ -package com.limelight.binding; - -import android.content.Context; - -import com.limelight.binding.audio.AndroidAudioRenderer; -import com.limelight.binding.crypto.AndroidCryptoProvider; -import com.limelight.nvstream.av.audio.AudioRenderer; -import com.limelight.nvstream.http.LimelightCryptoProvider; - -public class PlatformBinding { - public static LimelightCryptoProvider getCryptoProvider(Context c) { - return new AndroidCryptoProvider(c); - } -} +package com.limelight.binding; + +import android.content.Context; + +import com.limelight.binding.audio.AndroidAudioRenderer; +import com.limelight.binding.crypto.AndroidCryptoProvider; +import com.limelight.nvstream.av.audio.AudioRenderer; +import com.limelight.nvstream.http.LimelightCryptoProvider; + +public class PlatformBinding { + public static LimelightCryptoProvider getCryptoProvider(Context c) { + return new AndroidCryptoProvider(c); + } +} diff --git a/app/src/main/java/com/limelight/binding/audio/AndroidAudioRenderer.java b/app/src/main/java/com/limelight/binding/audio/AndroidAudioRenderer.java old mode 100644 new mode 100755 index dc25cc8912..53cbf6879a --- a/app/src/main/java/com/limelight/binding/audio/AndroidAudioRenderer.java +++ b/app/src/main/java/com/limelight/binding/audio/AndroidAudioRenderer.java @@ -1,233 +1,233 @@ -package com.limelight.binding.audio; - -import android.content.Context; -import android.content.Intent; -import android.media.AudioAttributes; -import android.media.AudioFormat; -import android.media.AudioManager; -import android.media.AudioTrack; -import android.media.audiofx.AudioEffect; -import android.os.Build; - -import com.limelight.LimeLog; -import com.limelight.nvstream.av.audio.AudioRenderer; -import com.limelight.nvstream.jni.MoonBridge; - -public class AndroidAudioRenderer implements AudioRenderer { - - private final Context context; - private final boolean enableAudioFx; - - private AudioTrack track; - - public AndroidAudioRenderer(Context context, boolean enableAudioFx) { - this.context = context; - this.enableAudioFx = enableAudioFx; - } - - private AudioTrack createAudioTrack(int channelConfig, int sampleRate, int bufferSize, boolean lowLatency) { - AudioAttributes.Builder attributesBuilder = new AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_GAME); - AudioFormat format = new AudioFormat.Builder() - .setEncoding(AudioFormat.ENCODING_PCM_16BIT) - .setSampleRate(sampleRate) - .setChannelMask(channelConfig) - .build(); - - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - // Use FLAG_LOW_LATENCY on L through N - if (lowLatency) { - attributesBuilder.setFlags(AudioAttributes.FLAG_LOW_LATENCY); - } - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - AudioTrack.Builder trackBuilder = new AudioTrack.Builder() - .setAudioFormat(format) - .setAudioAttributes(attributesBuilder.build()) - .setTransferMode(AudioTrack.MODE_STREAM) - .setBufferSizeInBytes(bufferSize); - - // Use PERFORMANCE_MODE_LOW_LATENCY on O and later - if (lowLatency) { - trackBuilder.setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY); - } - - return trackBuilder.build(); - } - else { - return new AudioTrack(attributesBuilder.build(), - format, - bufferSize, - AudioTrack.MODE_STREAM, - AudioManager.AUDIO_SESSION_ID_GENERATE); - } - } - - @Override - public int setup(MoonBridge.AudioConfiguration audioConfiguration, int sampleRate, int samplesPerFrame) { - int channelConfig; - int bytesPerFrame; - - switch (audioConfiguration.channelCount) - { - case 2: - channelConfig = AudioFormat.CHANNEL_OUT_STEREO; - break; - case 4: - channelConfig = AudioFormat.CHANNEL_OUT_QUAD; - break; - case 6: - channelConfig = AudioFormat.CHANNEL_OUT_5POINT1; - break; - case 8: - // AudioFormat.CHANNEL_OUT_7POINT1_SURROUND isn't available until Android 6.0, - // yet the CHANNEL_OUT_SIDE_LEFT and CHANNEL_OUT_SIDE_RIGHT constants were added - // in 5.0, so just hardcode the constant so we can work on Lollipop. - channelConfig = 0x000018fc; // AudioFormat.CHANNEL_OUT_7POINT1_SURROUND - break; - default: - LimeLog.severe("Decoder returned unhandled channel count"); - return -1; - } - - LimeLog.info("Audio channel config: "+String.format("0x%X", channelConfig)); - - bytesPerFrame = audioConfiguration.channelCount * samplesPerFrame * 2; - - // We're not supposed to request less than the minimum - // buffer size for our buffer, but it appears that we can - // do this on many devices and it lowers audio latency. - // We'll try the small buffer size first and if it fails, - // use the recommended larger buffer size. - - for (int i = 0; i < 4; i++) { - boolean lowLatency; - int bufferSize; - - // We will try: - // 1) Small buffer, low latency mode - // 2) Large buffer, low latency mode - // 3) Small buffer, standard mode - // 4) Large buffer, standard mode - - switch (i) { - case 0: - case 1: - lowLatency = true; - break; - case 2: - case 3: - lowLatency = false; - break; - default: - // Unreachable - throw new IllegalStateException(); - } - - switch (i) { - case 0: - case 2: - bufferSize = bytesPerFrame * 2; - break; - - case 1: - case 3: - // Try the larger buffer size - bufferSize = Math.max(AudioTrack.getMinBufferSize(sampleRate, - channelConfig, - AudioFormat.ENCODING_PCM_16BIT), - bytesPerFrame * 2); - - // Round to next frame - bufferSize = (((bufferSize + (bytesPerFrame - 1)) / bytesPerFrame) * bytesPerFrame); - break; - default: - // Unreachable - throw new IllegalStateException(); - } - - // Skip low latency options if hardware sample rate doesn't match the content - if (AudioTrack.getNativeOutputSampleRate(AudioManager.STREAM_MUSIC) != sampleRate && lowLatency) { - continue; - } - - // Skip low latency options when using audio effects, since low latency mode - // precludes the use of the audio effect pipeline (as of Android 13). - if (enableAudioFx && lowLatency) { - continue; - } - - try { - track = createAudioTrack(channelConfig, sampleRate, bufferSize, lowLatency); - track.play(); - - // Successfully created working AudioTrack. We're done here. - LimeLog.info("Audio track configuration: "+bufferSize+" "+lowLatency); - break; - } catch (Exception e) { - // Try to release the AudioTrack if we got far enough - e.printStackTrace(); - try { - if (track != null) { - track.release(); - track = null; - } - } catch (Exception ignored) {} - } - } - - if (track == null) { - // Couldn't create any audio track for playback - return -2; - } - - return 0; - } - - @Override - public void playDecodedAudio(short[] audioData) { - // Only queue up to 40 ms of pending audio data in addition to what AudioTrack is buffering for us. - if (MoonBridge.getPendingAudioDuration() < 40) { - // This will block until the write is completed. That can cause a backlog - // of pending audio data, so we do the above check to be able to bound - // latency at 40 ms in that situation. - track.write(audioData, 0, audioData.length); - } - else { - LimeLog.info("Too much pending audio data: " + MoonBridge.getPendingAudioDuration() +" ms"); - } - } - - @Override - public void start() { - if (enableAudioFx) { - // Open an audio effect control session to allow equalizers to apply audio effects - Intent i = new Intent(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION); - i.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, track.getAudioSessionId()); - i.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.getPackageName()); - i.putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_GAME); - context.sendBroadcast(i); - } - } - - @Override - public void stop() { - if (enableAudioFx) { - // Close our audio effect control session when we're stopping - Intent i = new Intent(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION); - i.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, track.getAudioSessionId()); - i.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.getPackageName()); - context.sendBroadcast(i); - } - } - - @Override - public void cleanup() { - // Immediately drop all pending data - track.pause(); - track.flush(); - - track.release(); - } -} +package com.limelight.binding.audio; + +import android.content.Context; +import android.content.Intent; +import android.media.AudioAttributes; +import android.media.AudioFormat; +import android.media.AudioManager; +import android.media.AudioTrack; +import android.media.audiofx.AudioEffect; +import android.os.Build; + +import com.limelight.LimeLog; +import com.limelight.nvstream.av.audio.AudioRenderer; +import com.limelight.nvstream.jni.MoonBridge; + +public class AndroidAudioRenderer implements AudioRenderer { + + private final Context context; + private final boolean enableAudioFx; + + private AudioTrack track; + + public AndroidAudioRenderer(Context context, boolean enableAudioFx) { + this.context = context; + this.enableAudioFx = enableAudioFx; + } + + private AudioTrack createAudioTrack(int channelConfig, int sampleRate, int bufferSize, boolean lowLatency) { + AudioAttributes.Builder attributesBuilder = new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_GAME); + AudioFormat format = new AudioFormat.Builder() + .setEncoding(AudioFormat.ENCODING_PCM_16BIT) + .setSampleRate(sampleRate) + .setChannelMask(channelConfig) + .build(); + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + // Use FLAG_LOW_LATENCY on L through N + if (lowLatency) { + attributesBuilder.setFlags(AudioAttributes.FLAG_LOW_LATENCY); + } + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + AudioTrack.Builder trackBuilder = new AudioTrack.Builder() + .setAudioFormat(format) + .setAudioAttributes(attributesBuilder.build()) + .setTransferMode(AudioTrack.MODE_STREAM) + .setBufferSizeInBytes(bufferSize); + + // Use PERFORMANCE_MODE_LOW_LATENCY on O and later + if (lowLatency) { + trackBuilder.setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY); + } + + return trackBuilder.build(); + } + else { + return new AudioTrack(attributesBuilder.build(), + format, + bufferSize, + AudioTrack.MODE_STREAM, + AudioManager.AUDIO_SESSION_ID_GENERATE); + } + } + + @Override + public int setup(MoonBridge.AudioConfiguration audioConfiguration, int sampleRate, int samplesPerFrame) { + int channelConfig; + int bytesPerFrame; + + switch (audioConfiguration.channelCount) + { + case 2: + channelConfig = AudioFormat.CHANNEL_OUT_STEREO; + break; + case 4: + channelConfig = AudioFormat.CHANNEL_OUT_QUAD; + break; + case 6: + channelConfig = AudioFormat.CHANNEL_OUT_5POINT1; + break; + case 8: + // AudioFormat.CHANNEL_OUT_7POINT1_SURROUND isn't available until Android 6.0, + // yet the CHANNEL_OUT_SIDE_LEFT and CHANNEL_OUT_SIDE_RIGHT constants were added + // in 5.0, so just hardcode the constant so we can work on Lollipop. + channelConfig = 0x000018fc; // AudioFormat.CHANNEL_OUT_7POINT1_SURROUND + break; + default: + LimeLog.severe("Decoder returned unhandled channel count"); + return -1; + } + + LimeLog.info("Audio channel config: "+String.format("0x%X", channelConfig)); + + bytesPerFrame = audioConfiguration.channelCount * samplesPerFrame * 2; + + // We're not supposed to request less than the minimum + // buffer size for our buffer, but it appears that we can + // do this on many devices and it lowers audio latency. + // We'll try the small buffer size first and if it fails, + // use the recommended larger buffer size. + + for (int i = 0; i < 4; i++) { + boolean lowLatency; + int bufferSize; + + // We will try: + // 1) Small buffer, low latency mode + // 2) Large buffer, low latency mode + // 3) Small buffer, standard mode + // 4) Large buffer, standard mode + + switch (i) { + case 0: + case 1: + lowLatency = true; + break; + case 2: + case 3: + lowLatency = false; + break; + default: + // Unreachable + throw new IllegalStateException(); + } + + switch (i) { + case 0: + case 2: + bufferSize = bytesPerFrame * 2; + break; + + case 1: + case 3: + // Try the larger buffer size + bufferSize = Math.max(AudioTrack.getMinBufferSize(sampleRate, + channelConfig, + AudioFormat.ENCODING_PCM_16BIT), + bytesPerFrame * 2); + + // Round to next frame + bufferSize = (((bufferSize + (bytesPerFrame - 1)) / bytesPerFrame) * bytesPerFrame); + break; + default: + // Unreachable + throw new IllegalStateException(); + } + + // Skip low latency options if hardware sample rate doesn't match the content + if (AudioTrack.getNativeOutputSampleRate(AudioManager.STREAM_MUSIC) != sampleRate && lowLatency) { + continue; + } + + // Skip low latency options when using audio effects, since low latency mode + // precludes the use of the audio effect pipeline (as of Android 13). + if (enableAudioFx && lowLatency) { + continue; + } + + try { + track = createAudioTrack(channelConfig, sampleRate, bufferSize, lowLatency); + track.play(); + + // Successfully created working AudioTrack. We're done here. + LimeLog.info("Audio track configuration: "+bufferSize+" "+lowLatency); + break; + } catch (Exception e) { + // Try to release the AudioTrack if we got far enough + e.printStackTrace(); + try { + if (track != null) { + track.release(); + track = null; + } + } catch (Exception ignored) {} + } + } + + if (track == null) { + // Couldn't create any audio track for playback + return -2; + } + + return 0; + } + + @Override + public void playDecodedAudio(short[] audioData) { + // Only queue up to 40 ms of pending audio data in addition to what AudioTrack is buffering for us. + if (MoonBridge.getPendingAudioDuration() < 40) { + // This will block until the write is completed. That can cause a backlog + // of pending audio data, so we do the above check to be able to bound + // latency at 40 ms in that situation. + track.write(audioData, 0, audioData.length); + } + else { + LimeLog.info("Too much pending audio data: " + MoonBridge.getPendingAudioDuration() +" ms"); + } + } + + @Override + public void start() { + if (enableAudioFx) { + // Open an audio effect control session to allow equalizers to apply audio effects + Intent i = new Intent(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION); + i.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, track.getAudioSessionId()); + i.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.getPackageName()); + i.putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_GAME); + context.sendBroadcast(i); + } + } + + @Override + public void stop() { + if (enableAudioFx) { + // Close our audio effect control session when we're stopping + Intent i = new Intent(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION); + i.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, track.getAudioSessionId()); + i.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.getPackageName()); + context.sendBroadcast(i); + } + } + + @Override + public void cleanup() { + // Immediately drop all pending data + track.pause(); + track.flush(); + + track.release(); + } +} diff --git a/app/src/main/java/com/limelight/binding/crypto/AndroidCryptoProvider.java b/app/src/main/java/com/limelight/binding/crypto/AndroidCryptoProvider.java old mode 100644 new mode 100755 index b3252dba50..3e855429c9 --- a/app/src/main/java/com/limelight/binding/crypto/AndroidCryptoProvider.java +++ b/app/src/main/java/com/limelight/binding/crypto/AndroidCryptoProvider.java @@ -1,259 +1,259 @@ -package com.limelight.binding.crypto; - -import java.io.ByteArrayInputStream; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.OutputStreamWriter; -import java.io.StringWriter; -import java.math.BigInteger; -import java.security.KeyFactory; -import java.security.KeyPair; -import java.security.KeyPairGenerator; -import java.security.NoSuchAlgorithmException; -import java.security.PrivateKey; -import java.security.Provider; -import java.security.SecureRandom; -import java.security.cert.CertificateException; -import java.security.cert.CertificateFactory; -import java.security.cert.X509Certificate; -import java.security.spec.InvalidKeySpecException; -import java.security.spec.PKCS8EncodedKeySpec; -import java.util.Calendar; -import java.util.Date; -import java.util.Locale; - -import org.bouncycastle.asn1.x500.X500Name; -import org.bouncycastle.asn1.x500.X500NameBuilder; -import org.bouncycastle.asn1.x500.style.BCStyle; -import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; -import org.bouncycastle.cert.X509v3CertificateBuilder; -import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; -import org.bouncycastle.jce.provider.BouncyCastleProvider; -import org.bouncycastle.openssl.jcajce.JcaPEMWriter; -import org.bouncycastle.operator.ContentSigner; -import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.util.Base64; - -import com.limelight.LimeLog; -import com.limelight.nvstream.http.LimelightCryptoProvider; - -public class AndroidCryptoProvider implements LimelightCryptoProvider { - - private final File certFile; - private final File keyFile; - - private X509Certificate cert; - private PrivateKey key; - private byte[] pemCertBytes; - - private static final Object globalCryptoLock = new Object(); - - private static final Provider bcProvider = new BouncyCastleProvider(); - - public AndroidCryptoProvider(Context c) { - String dataPath = c.getFilesDir().getAbsolutePath(); - - certFile = new File(dataPath + File.separator + "client.crt"); - keyFile = new File(dataPath + File.separator + "client.key"); - } - - private byte[] loadFileToBytes(File f) { - if (!f.exists()) { - return null; - } - - try (final FileInputStream fin = new FileInputStream(f)) { - byte[] fileData = new byte[(int) f.length()]; - if (fin.read(fileData) != f.length()) { - // Failed to read - fileData = null; - } - return fileData; - } catch (IOException e) { - return null; - } - } - - private boolean loadCertKeyPair() { - byte[] certBytes = loadFileToBytes(certFile); - byte[] keyBytes = loadFileToBytes(keyFile); - - // If either file was missing, we definitely can't succeed - if (certBytes == null || keyBytes == null) { - LimeLog.info("Missing cert or key; need to generate a new one"); - return false; - } - - try { - CertificateFactory certFactory = CertificateFactory.getInstance("X.509", bcProvider); - cert = (X509Certificate) certFactory.generateCertificate(new ByteArrayInputStream(certBytes)); - pemCertBytes = certBytes; - KeyFactory keyFactory = KeyFactory.getInstance("RSA", bcProvider); - key = keyFactory.generatePrivate(new PKCS8EncodedKeySpec(keyBytes)); - } catch (CertificateException e) { - // May happen if the cert is corrupt - LimeLog.warning("Corrupted certificate"); - return false; - } catch (NoSuchAlgorithmException e) { - throw new RuntimeException(e); - } catch (InvalidKeySpecException e) { - // May happen if the key is corrupt - LimeLog.warning("Corrupted key"); - return false; - } - - return true; - } - - @SuppressLint("TrulyRandom") - private boolean generateCertKeyPair() { - byte[] snBytes = new byte[8]; - new SecureRandom().nextBytes(snBytes); - - KeyPair keyPair; - try { - KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", bcProvider); - keyPairGenerator.initialize(2048); - keyPair = keyPairGenerator.generateKeyPair(); - } catch (NoSuchAlgorithmException e) { - throw new RuntimeException(e); - } - - Date now = new Date(); - - // Expires in 20 years - Calendar calendar = Calendar.getInstance(); - calendar.setTime(now); - calendar.add(Calendar.YEAR, 20); - Date expirationDate = calendar.getTime(); - - BigInteger serial = new BigInteger(snBytes).abs(); - - X500NameBuilder nameBuilder = new X500NameBuilder(BCStyle.INSTANCE); - nameBuilder.addRDN(BCStyle.CN, "NVIDIA GameStream Client"); - X500Name name = nameBuilder.build(); - - X509v3CertificateBuilder certBuilder = new X509v3CertificateBuilder(name, serial, now, expirationDate, Locale.ENGLISH, name, - SubjectPublicKeyInfo.getInstance(keyPair.getPublic().getEncoded())); - - try { - ContentSigner sigGen = new JcaContentSignerBuilder("SHA256withRSA").setProvider(bcProvider).build(keyPair.getPrivate()); - cert = new JcaX509CertificateConverter().setProvider(bcProvider).getCertificate(certBuilder.build(sigGen)); - key = keyPair.getPrivate(); - } catch (Exception e) { - throw new RuntimeException(e); - } - - LimeLog.info("Generated a new key pair"); - - // Save the resulting pair - saveCertKeyPair(); - - return true; - } - - private void saveCertKeyPair() { - try (final FileOutputStream certOut = new FileOutputStream(certFile); - final FileOutputStream keyOut = new FileOutputStream(keyFile) - ) { - // Write the certificate in OpenSSL PEM format (important for the server) - StringWriter strWriter = new StringWriter(); - try (final JcaPEMWriter pemWriter = new JcaPEMWriter(strWriter)) { - pemWriter.writeObject(cert); - } - - // Line endings MUST be UNIX for the PC to accept the cert properly - try (final OutputStreamWriter certWriter = new OutputStreamWriter(certOut)) { - String pemStr = strWriter.getBuffer().toString(); - for (int i = 0; i < pemStr.length(); i++) { - char c = pemStr.charAt(i); - if (c != '\r') - certWriter.append(c); - } - } - - // Write the private out in PKCS8 format - keyOut.write(key.getEncoded()); - - LimeLog.info("Saved generated key pair to disk"); - } catch (IOException e) { - // This isn't good because it means we'll have - // to re-pair next time - e.printStackTrace(); - } - } - - public X509Certificate getClientCertificate() { - // Use a lock here to ensure only one guy will be generating or loading - // the certificate and key at a time - synchronized (globalCryptoLock) { - // Return a loaded cert if we have one - if (cert != null) { - return cert; - } - - // No loaded cert yet, let's see if we have one on disk - if (loadCertKeyPair()) { - // Got one - return cert; - } - - // Try to generate a new key pair - if (!generateCertKeyPair()) { - // Failed - return null; - } - - // Load the generated pair - loadCertKeyPair(); - return cert; - } - } - - public PrivateKey getClientPrivateKey() { - // Use a lock here to ensure only one guy will be generating or loading - // the certificate and key at a time - synchronized (globalCryptoLock) { - // Return a loaded key if we have one - if (key != null) { - return key; - } - - // No loaded key yet, let's see if we have one on disk - if (loadCertKeyPair()) { - // Got one - return key; - } - - // Try to generate a new key pair - if (!generateCertKeyPair()) { - // Failed - return null; - } - - // Load the generated pair - loadCertKeyPair(); - return key; - } - } - - public byte[] getPemEncodedClientCertificate() { - synchronized (globalCryptoLock) { - // Call our helper function to do the cert loading/generation for us - getClientCertificate(); - - // Return a cached value if we have it - return pemCertBytes; - } - } - - @Override - public String encodeBase64String(byte[] data) { - return Base64.encodeToString(data, Base64.NO_WRAP); - } -} +package com.limelight.binding.crypto; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.StringWriter; +import java.math.BigInteger; +import java.security.KeyFactory; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.Provider; +import java.security.SecureRandom; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Calendar; +import java.util.Date; +import java.util.Locale; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x500.X500NameBuilder; +import org.bouncycastle.asn1.x500.style.BCStyle; +import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.openssl.jcajce.JcaPEMWriter; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.util.Base64; + +import com.limelight.LimeLog; +import com.limelight.nvstream.http.LimelightCryptoProvider; + +public class AndroidCryptoProvider implements LimelightCryptoProvider { + + private final File certFile; + private final File keyFile; + + private X509Certificate cert; + private PrivateKey key; + private byte[] pemCertBytes; + + private static final Object globalCryptoLock = new Object(); + + private static final Provider bcProvider = new BouncyCastleProvider(); + + public AndroidCryptoProvider(Context c) { + String dataPath = c.getFilesDir().getAbsolutePath(); + + certFile = new File(dataPath + File.separator + "client.crt"); + keyFile = new File(dataPath + File.separator + "client.key"); + } + + private byte[] loadFileToBytes(File f) { + if (!f.exists()) { + return null; + } + + try (final FileInputStream fin = new FileInputStream(f)) { + byte[] fileData = new byte[(int) f.length()]; + if (fin.read(fileData) != f.length()) { + // Failed to read + fileData = null; + } + return fileData; + } catch (IOException e) { + return null; + } + } + + private boolean loadCertKeyPair() { + byte[] certBytes = loadFileToBytes(certFile); + byte[] keyBytes = loadFileToBytes(keyFile); + + // If either file was missing, we definitely can't succeed + if (certBytes == null || keyBytes == null) { + LimeLog.info("Missing cert or key; need to generate a new one"); + return false; + } + + try { + CertificateFactory certFactory = CertificateFactory.getInstance("X.509", bcProvider); + cert = (X509Certificate) certFactory.generateCertificate(new ByteArrayInputStream(certBytes)); + pemCertBytes = certBytes; + KeyFactory keyFactory = KeyFactory.getInstance("RSA", bcProvider); + key = keyFactory.generatePrivate(new PKCS8EncodedKeySpec(keyBytes)); + } catch (CertificateException e) { + // May happen if the cert is corrupt + LimeLog.warning("Corrupted certificate"); + return false; + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } catch (InvalidKeySpecException e) { + // May happen if the key is corrupt + LimeLog.warning("Corrupted key"); + return false; + } + + return true; + } + + @SuppressLint("TrulyRandom") + private boolean generateCertKeyPair() { + byte[] snBytes = new byte[8]; + new SecureRandom().nextBytes(snBytes); + + KeyPair keyPair; + try { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", bcProvider); + keyPairGenerator.initialize(2048); + keyPair = keyPairGenerator.generateKeyPair(); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + + Date now = new Date(); + + // Expires in 20 years + Calendar calendar = Calendar.getInstance(); + calendar.setTime(now); + calendar.add(Calendar.YEAR, 20); + Date expirationDate = calendar.getTime(); + + BigInteger serial = new BigInteger(snBytes).abs(); + + X500NameBuilder nameBuilder = new X500NameBuilder(BCStyle.INSTANCE); + nameBuilder.addRDN(BCStyle.CN, "NVIDIA GameStream Client"); + X500Name name = nameBuilder.build(); + + X509v3CertificateBuilder certBuilder = new X509v3CertificateBuilder(name, serial, now, expirationDate, Locale.ENGLISH, name, + SubjectPublicKeyInfo.getInstance(keyPair.getPublic().getEncoded())); + + try { + ContentSigner sigGen = new JcaContentSignerBuilder("SHA256withRSA").setProvider(bcProvider).build(keyPair.getPrivate()); + cert = new JcaX509CertificateConverter().setProvider(bcProvider).getCertificate(certBuilder.build(sigGen)); + key = keyPair.getPrivate(); + } catch (Exception e) { + throw new RuntimeException(e); + } + + LimeLog.info("Generated a new key pair"); + + // Save the resulting pair + saveCertKeyPair(); + + return true; + } + + private void saveCertKeyPair() { + try (final FileOutputStream certOut = new FileOutputStream(certFile); + final FileOutputStream keyOut = new FileOutputStream(keyFile) + ) { + // Write the certificate in OpenSSL PEM format (important for the server) + StringWriter strWriter = new StringWriter(); + try (final JcaPEMWriter pemWriter = new JcaPEMWriter(strWriter)) { + pemWriter.writeObject(cert); + } + + // Line endings MUST be UNIX for the PC to accept the cert properly + try (final OutputStreamWriter certWriter = new OutputStreamWriter(certOut)) { + String pemStr = strWriter.getBuffer().toString(); + for (int i = 0; i < pemStr.length(); i++) { + char c = pemStr.charAt(i); + if (c != '\r') + certWriter.append(c); + } + } + + // Write the private out in PKCS8 format + keyOut.write(key.getEncoded()); + + LimeLog.info("Saved generated key pair to disk"); + } catch (IOException e) { + // This isn't good because it means we'll have + // to re-pair next time + e.printStackTrace(); + } + } + + public X509Certificate getClientCertificate() { + // Use a lock here to ensure only one guy will be generating or loading + // the certificate and key at a time + synchronized (globalCryptoLock) { + // Return a loaded cert if we have one + if (cert != null) { + return cert; + } + + // No loaded cert yet, let's see if we have one on disk + if (loadCertKeyPair()) { + // Got one + return cert; + } + + // Try to generate a new key pair + if (!generateCertKeyPair()) { + // Failed + return null; + } + + // Load the generated pair + loadCertKeyPair(); + return cert; + } + } + + public PrivateKey getClientPrivateKey() { + // Use a lock here to ensure only one guy will be generating or loading + // the certificate and key at a time + synchronized (globalCryptoLock) { + // Return a loaded key if we have one + if (key != null) { + return key; + } + + // No loaded key yet, let's see if we have one on disk + if (loadCertKeyPair()) { + // Got one + return key; + } + + // Try to generate a new key pair + if (!generateCertKeyPair()) { + // Failed + return null; + } + + // Load the generated pair + loadCertKeyPair(); + return key; + } + } + + public byte[] getPemEncodedClientCertificate() { + synchronized (globalCryptoLock) { + // Call our helper function to do the cert loading/generation for us + getClientCertificate(); + + // Return a cached value if we have it + return pemCertBytes; + } + } + + @Override + public String encodeBase64String(byte[] data) { + return Base64.encodeToString(data, Base64.NO_WRAP); + } +} diff --git a/app/src/main/java/com/limelight/binding/input/ControllerHandler.java b/app/src/main/java/com/limelight/binding/input/ControllerHandler.java old mode 100644 new mode 100755 index f7c8c0d116..54a337e9d4 --- a/app/src/main/java/com/limelight/binding/input/ControllerHandler.java +++ b/app/src/main/java/com/limelight/binding/input/ControllerHandler.java @@ -1,3265 +1,3338 @@ -package com.limelight.binding.input; - -import android.annotation.TargetApi; -import android.app.Activity; -import android.content.Context; -import android.hardware.BatteryState; -import android.hardware.Sensor; -import android.hardware.SensorEvent; -import android.hardware.SensorEventListener; -import android.hardware.SensorManager; -import android.hardware.input.InputManager; -import android.hardware.lights.Light; -import android.hardware.lights.LightState; -import android.hardware.lights.LightsManager; -import android.hardware.lights.LightsRequest; -import android.hardware.usb.UsbDevice; -import android.hardware.usb.UsbManager; -import android.media.AudioAttributes; -import android.os.Build; -import android.os.CombinedVibration; -import android.os.Handler; -import android.os.HandlerThread; -import android.os.Looper; -import android.os.VibrationAttributes; -import android.os.VibrationEffect; -import android.os.Vibrator; -import android.os.VibratorManager; -import android.util.SparseArray; -import android.view.InputDevice; -import android.view.InputEvent; -import android.view.KeyEvent; -import android.view.MotionEvent; -import android.view.Surface; -import android.widget.Toast; - -import com.limelight.LimeLog; -import com.limelight.R; -import com.limelight.binding.input.driver.AbstractController; -import com.limelight.binding.input.driver.UsbDriverListener; -import com.limelight.binding.input.driver.UsbDriverService; -import com.limelight.nvstream.NvConnection; -import com.limelight.nvstream.input.ControllerPacket; -import com.limelight.nvstream.input.MouseButtonPacket; -import com.limelight.nvstream.jni.MoonBridge; -import com.limelight.preferences.PreferenceConfiguration; -import com.limelight.ui.GameGestures; -import com.limelight.utils.Vector2d; - -import org.cgutman.shieldcontrollerextensions.SceChargingState; -import org.cgutman.shieldcontrollerextensions.SceConnectionType; -import org.cgutman.shieldcontrollerextensions.SceManager; - -import java.lang.reflect.InvocationTargetException; -import java.util.Map; - -public class ControllerHandler implements InputManager.InputDeviceListener, UsbDriverListener { - - private static final int MAXIMUM_BUMPER_UP_DELAY_MS = 100; - - private static final int START_DOWN_TIME_MOUSE_MODE_MS = 750; - - private static final int MINIMUM_BUTTON_DOWN_TIME_MS = 25; - - private static final int EMULATING_SPECIAL = 0x1; - private static final int EMULATING_SELECT = 0x2; - private static final int EMULATING_TOUCHPAD = 0x4; - - private static final short MAX_GAMEPADS = 16; // Limited by bits in activeGamepadMask - - private static final int BATTERY_RECHECK_INTERVAL_MS = 120 * 1000; - - private static final Map ANDROID_TO_LI_BUTTON_MAP = Map.ofEntries( - Map.entry(KeyEvent.KEYCODE_BUTTON_A, ControllerPacket.A_FLAG), - Map.entry(KeyEvent.KEYCODE_BUTTON_B, ControllerPacket.B_FLAG), - Map.entry(KeyEvent.KEYCODE_BUTTON_X, ControllerPacket.X_FLAG), - Map.entry(KeyEvent.KEYCODE_BUTTON_Y, ControllerPacket.Y_FLAG), - Map.entry(KeyEvent.KEYCODE_DPAD_UP, ControllerPacket.UP_FLAG), - Map.entry(KeyEvent.KEYCODE_DPAD_DOWN, ControllerPacket.DOWN_FLAG), - Map.entry(KeyEvent.KEYCODE_DPAD_LEFT, ControllerPacket.LEFT_FLAG), - Map.entry(KeyEvent.KEYCODE_DPAD_RIGHT, ControllerPacket.RIGHT_FLAG), - Map.entry(KeyEvent.KEYCODE_DPAD_UP_LEFT, ControllerPacket.UP_FLAG | ControllerPacket.LEFT_FLAG), - Map.entry(KeyEvent.KEYCODE_DPAD_UP_RIGHT, ControllerPacket.UP_FLAG | ControllerPacket.RIGHT_FLAG), - Map.entry(KeyEvent.KEYCODE_DPAD_DOWN_LEFT, ControllerPacket.DOWN_FLAG | ControllerPacket.LEFT_FLAG), - Map.entry(KeyEvent.KEYCODE_DPAD_DOWN_RIGHT, ControllerPacket.DOWN_FLAG | ControllerPacket.RIGHT_FLAG), - Map.entry(KeyEvent.KEYCODE_BUTTON_L1, ControllerPacket.LB_FLAG), - Map.entry(KeyEvent.KEYCODE_BUTTON_R1, ControllerPacket.RB_FLAG), - Map.entry(KeyEvent.KEYCODE_BUTTON_THUMBL, ControllerPacket.LS_CLK_FLAG), - Map.entry(KeyEvent.KEYCODE_BUTTON_THUMBR, ControllerPacket.RS_CLK_FLAG), - Map.entry(KeyEvent.KEYCODE_BUTTON_START, ControllerPacket.PLAY_FLAG), - Map.entry(KeyEvent.KEYCODE_MENU, ControllerPacket.PLAY_FLAG), - Map.entry(KeyEvent.KEYCODE_BUTTON_SELECT, ControllerPacket.BACK_FLAG), - Map.entry(KeyEvent.KEYCODE_BACK, ControllerPacket.BACK_FLAG), - Map.entry(KeyEvent.KEYCODE_BUTTON_MODE, ControllerPacket.SPECIAL_BUTTON_FLAG), - - // This is the Xbox Series X Share button - Map.entry(KeyEvent.KEYCODE_MEDIA_RECORD, ControllerPacket.MISC_FLAG), - - // This is a weird one, but it's what Android does prior to 4.10 kernels - // where DualShock/DualSense touchpads weren't mapped as separate devices. - // https://android.googlesource.com/platform/frameworks/base/+/master/data/keyboards/Vendor_054c_Product_0ce6_fallback.kl - // https://android.googlesource.com/platform/frameworks/base/+/master/data/keyboards/Vendor_054c_Product_09cc.kl - Map.entry(KeyEvent.KEYCODE_BUTTON_1, ControllerPacket.TOUCHPAD_FLAG) - - // FIXME: Paddles? - ); - - private final Vector2d inputVector = new Vector2d(); - - private final SparseArray inputDeviceContexts = new SparseArray<>(); - private final SparseArray usbDeviceContexts = new SparseArray<>(); - - private final NvConnection conn; - private final Activity activityContext; - private final double stickDeadzone; - private final InputDeviceContext defaultContext = new InputDeviceContext(); - private final GameGestures gestures; - private final InputManager inputManager; - private final Vibrator deviceVibrator; - private final VibratorManager deviceVibratorManager; - private final SensorManager deviceSensorManager; - private final SceManager sceManager; - private final Handler mainThreadHandler; - private final HandlerThread backgroundHandlerThread; - private final Handler backgroundThreadHandler; - private boolean hasGameController; - private boolean stopped = false; - - private final PreferenceConfiguration prefConfig; - private short currentControllers, initialControllers; - - public ControllerHandler(Activity activityContext, NvConnection conn, GameGestures gestures, PreferenceConfiguration prefConfig) { - this.activityContext = activityContext; - this.conn = conn; - this.gestures = gestures; - this.prefConfig = prefConfig; - this.deviceVibrator = (Vibrator) activityContext.getSystemService(Context.VIBRATOR_SERVICE); - this.deviceSensorManager = (SensorManager) activityContext.getSystemService(Context.SENSOR_SERVICE); - this.inputManager = (InputManager) activityContext.getSystemService(Context.INPUT_SERVICE); - this.mainThreadHandler = new Handler(Looper.getMainLooper()); - - // Create a HandlerThread to process battery state updates. These can be slow enough - // that they lead to ANRs if we do them on the main thread. - this.backgroundHandlerThread = new HandlerThread("ControllerHandler"); - this.backgroundHandlerThread.start(); - this.backgroundThreadHandler = new Handler(backgroundHandlerThread.getLooper()); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - this.deviceVibratorManager = (VibratorManager) activityContext.getSystemService(Context.VIBRATOR_MANAGER_SERVICE); - } - else { - this.deviceVibratorManager = null; - } - - this.sceManager = new SceManager(activityContext); - this.sceManager.start(); - - int deadzonePercentage = prefConfig.deadzonePercentage; - - int[] ids = InputDevice.getDeviceIds(); - for (int id : ids) { - InputDevice dev = InputDevice.getDevice(id); - if (dev == null) { - // This device was removed during enumeration - continue; - } - if ((dev.getSources() & InputDevice.SOURCE_JOYSTICK) != 0 || - (dev.getSources() & InputDevice.SOURCE_GAMEPAD) != 0) { - // This looks like a gamepad, but we'll check X and Y to be sure - if (getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_X) != null && - getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_Y) != null) { - // This is a gamepad - hasGameController = true; - } - } - } - - // 1% is the lowest possible deadzone we support - if (deadzonePercentage <= 0) { - deadzonePercentage = 1; - } - - this.stickDeadzone = (double)deadzonePercentage / 100.0; - - // Initialize the default context for events with no device - defaultContext.leftStickXAxis = MotionEvent.AXIS_X; - defaultContext.leftStickYAxis = MotionEvent.AXIS_Y; - defaultContext.leftStickDeadzoneRadius = (float) stickDeadzone; - defaultContext.rightStickXAxis = MotionEvent.AXIS_Z; - defaultContext.rightStickYAxis = MotionEvent.AXIS_RZ; - defaultContext.rightStickDeadzoneRadius = (float) stickDeadzone; - defaultContext.leftTriggerAxis = MotionEvent.AXIS_BRAKE; - defaultContext.rightTriggerAxis = MotionEvent.AXIS_GAS; - defaultContext.hatXAxis = MotionEvent.AXIS_HAT_X; - defaultContext.hatYAxis = MotionEvent.AXIS_HAT_Y; - defaultContext.controllerNumber = (short) 0; - defaultContext.assignedControllerNumber = true; - defaultContext.external = false; - - // Some devices (GPD XD) have a back button which sends input events - // with device ID == 0. This hits the default context which would normally - // consume these. Instead, let's ignore them since that's probably the - // most likely case. - defaultContext.ignoreBack = true; - - // Get the initially attached set of gamepads. As each gamepad receives - // its initial InputEvent, we will move these from this set onto the - // currentControllers set which will allow them to properly unplug - // if they are removed. - initialControllers = getAttachedControllerMask(activityContext); - - // Register ourselves for input device notifications - inputManager.registerInputDeviceListener(this, null); - } - - private static InputDevice.MotionRange getMotionRangeForJoystickAxis(InputDevice dev, int axis) { - InputDevice.MotionRange range; - - // First get the axis for SOURCE_JOYSTICK - range = dev.getMotionRange(axis, InputDevice.SOURCE_JOYSTICK); - if (range == null) { - // Now try the axis for SOURCE_GAMEPAD - range = dev.getMotionRange(axis, InputDevice.SOURCE_GAMEPAD); - } - - return range; - } - - @Override - public void onInputDeviceAdded(int deviceId) { - // Nothing happening here yet - } - - @Override - public void onInputDeviceRemoved(int deviceId) { - InputDeviceContext context = inputDeviceContexts.get(deviceId); - if (context != null) { - LimeLog.info("Removed controller: "+context.name+" ("+deviceId+")"); - releaseControllerNumber(context); - context.destroy(); - inputDeviceContexts.remove(deviceId); - } - } - - // This can happen when gaining/losing input focus with some devices. - // Input devices that have a trackpad may gain/lose AXIS_RELATIVE_X/Y. - @Override - public void onInputDeviceChanged(int deviceId) { - InputDevice device = InputDevice.getDevice(deviceId); - if (device == null) { - return; - } - - // If we don't have a context for this device, we don't need to update anything - InputDeviceContext existingContext = inputDeviceContexts.get(deviceId); - if (existingContext == null) { - return; - } - - LimeLog.info("Device changed: "+existingContext.name+" ("+deviceId+")"); - - // Migrate the existing context into this new one by moving any stateful elements - InputDeviceContext newContext = createInputDeviceContextForDevice(device); - newContext.migrateContext(existingContext); - inputDeviceContexts.put(deviceId, newContext); - } - - public void stop() { - if (stopped) { - return; - } - - // Stop new device contexts from being created or used - stopped = true; - - // Unregister our input device callbacks - inputManager.unregisterInputDeviceListener(this); - - for (int i = 0; i < inputDeviceContexts.size(); i++) { - InputDeviceContext deviceContext = inputDeviceContexts.valueAt(i); - deviceContext.destroy(); - } - - for (int i = 0; i < usbDeviceContexts.size(); i++) { - UsbDeviceContext deviceContext = usbDeviceContexts.valueAt(i); - deviceContext.destroy(); - } - - deviceVibrator.cancel(); - } - - public void destroy() { - if (!stopped) { - stop(); - } - - sceManager.stop(); - backgroundHandlerThread.quit(); - } - - public void disableSensors() { - for (int i = 0; i < inputDeviceContexts.size(); i++) { - InputDeviceContext deviceContext = inputDeviceContexts.valueAt(i); - deviceContext.disableSensors(); - } - } - - public void enableSensors() { - if (stopped) { - return; - } - - for (int i = 0; i < inputDeviceContexts.size(); i++) { - InputDeviceContext deviceContext = inputDeviceContexts.valueAt(i); - deviceContext.enableSensors(); - } - } - - private static boolean hasJoystickAxes(InputDevice device) { - return (device.getSources() & InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK && - getMotionRangeForJoystickAxis(device, MotionEvent.AXIS_X) != null && - getMotionRangeForJoystickAxis(device, MotionEvent.AXIS_Y) != null; - } - - private static boolean hasGamepadButtons(InputDevice device) { - return (device.getSources() & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD; - } - - public static boolean isGameControllerDevice(InputDevice device) { - if (device == null) { - return true; - } - - if (hasJoystickAxes(device) || hasGamepadButtons(device)) { - // Has real joystick axes or gamepad buttons - return true; - } - - // HACK for https://issuetracker.google.com/issues/163120692 - if (Build.VERSION.SDK_INT == Build.VERSION_CODES.R) { - if (device.getId() == -1) { - // This "virtual" device could be input from any of the attached devices. - // Look to see if any gamepads are connected. - int[] ids = InputDevice.getDeviceIds(); - for (int id : ids) { - InputDevice dev = InputDevice.getDevice(id); - if (dev == null) { - // This device was removed during enumeration - continue; - } - - // If there are any gamepad devices connected, we'll - // report that this virtual device is a gamepad. - if (hasJoystickAxes(dev) || hasGamepadButtons(dev)) { - return true; - } - } - } - } - - // Otherwise, we'll try anything that claims to be a non-alphabetic keyboard - return device.getKeyboardType() != InputDevice.KEYBOARD_TYPE_ALPHABETIC; - } - - public static short getAttachedControllerMask(Context context) { - int count = 0; - short mask = 0; - - // Count all input devices that are gamepads - InputManager im = (InputManager) context.getSystemService(Context.INPUT_SERVICE); - for (int id : im.getInputDeviceIds()) { - InputDevice dev = im.getInputDevice(id); - if (dev == null) { - continue; - } - - if (hasJoystickAxes(dev)) { - LimeLog.info("Counting InputDevice: "+dev.getName()); - mask |= 1 << count++; - } - } - - // Count all USB devices that match our drivers - if (PreferenceConfiguration.readPreferences(context).usbDriver) { - UsbManager usbManager = (UsbManager) context.getSystemService(Context.USB_SERVICE); - if (usbManager != null) { - for (UsbDevice dev : usbManager.getDeviceList().values()) { - // We explicitly check not to claim devices that appear as InputDevices - // otherwise we will double count them. - if (UsbDriverService.shouldClaimDevice(dev, false) && - !UsbDriverService.isRecognizedInputDevice(dev)) { - LimeLog.info("Counting UsbDevice: "+dev.getDeviceName()); - mask |= 1 << count++; - } - } - } - } - - if (PreferenceConfiguration.readPreferences(context).onscreenController) { - LimeLog.info("Counting OSC gamepad"); - mask |= 1; - } - - LimeLog.info("Enumerated "+count+" gamepads"); - return mask; - } - - private void releaseControllerNumber(GenericControllerContext context) { - // If we reserved a controller number, remove that reservation - if (context.reservedControllerNumber) { - LimeLog.info("Controller number "+context.controllerNumber+" is now available"); - currentControllers &= ~(1 << context.controllerNumber); - } - - // If this device sent data as a gamepad, zero the values before removing. - // We must do this after clearing the currentControllers entry so this - // causes the device to be removed on the server PC. - if (context.assignedControllerNumber) { - conn.sendControllerInput(context.controllerNumber, getActiveControllerMask(), - (short) 0, - (byte) 0, (byte) 0, - (short) 0, (short) 0, - (short) 0, (short) 0); - } - } - - private boolean isAssociatedJoystick(InputDevice originalDevice, InputDevice possibleAssociatedJoystick) { - if (possibleAssociatedJoystick == null) { - return false; - } - - // This can't be an associated joystick if it's not a joystick - if ((possibleAssociatedJoystick.getSources() & InputDevice.SOURCE_JOYSTICK) != InputDevice.SOURCE_JOYSTICK) { - return false; - } - - // Make sure the device names *don't* match in order to prevent us from accidentally matching - // on another of the exact same device. - if (possibleAssociatedJoystick.getName().equals(originalDevice.getName())) { - return false; - } - - // Make sure the descriptor matches. This can match in cases where two of the exact same - // input device are connected, so we perform the name check to exclude that case. - if (!possibleAssociatedJoystick.getDescriptor().equals(originalDevice.getDescriptor())) { - return false; - } - - return true; - } - - // Called before sending input but after we've determined that this - // is definitely a controller (not a keyboard, mouse, or something else) - private void assignControllerNumberIfNeeded(GenericControllerContext context) { - if (context.assignedControllerNumber) { - return; - } - - if (context instanceof InputDeviceContext) { - InputDeviceContext devContext = (InputDeviceContext) context; - - LimeLog.info(devContext.name+" ("+context.id+") needs a controller number assigned"); - if (!devContext.external) { - LimeLog.info("Built-in buttons hardcoded as controller 0"); - context.controllerNumber = 0; - } - else if (prefConfig.multiController && devContext.hasJoystickAxes) { - context.controllerNumber = 0; - - LimeLog.info("Reserving the next available controller number"); - for (short i = 0; i < MAX_GAMEPADS; i++) { - if ((currentControllers & (1 << i)) == 0) { - // Found an unused controller value - currentControllers |= (1 << i); - - // Take this value out of the initial gamepad set - initialControllers &= ~(1 << i); - - context.controllerNumber = i; - context.reservedControllerNumber = true; - break; - } - } - } - else if (!devContext.hasJoystickAxes) { - // If this device doesn't have joystick axes, it may be an input device associated - // with another joystick (like a PS4 touchpad). We'll propagate that joystick's - // controller number to this associated device. - - context.controllerNumber = 0; - - // For the DS4 case, the associated joystick is the next device after the touchpad. - // We'll try the opposite case too, just to be a little future-proof. - InputDevice associatedDevice = InputDevice.getDevice(devContext.id + 1); - if (!isAssociatedJoystick(devContext.inputDevice, associatedDevice)) { - associatedDevice = InputDevice.getDevice(devContext.id - 1); - if (!isAssociatedJoystick(devContext.inputDevice, associatedDevice)) { - LimeLog.info("No associated joystick device found"); - associatedDevice = null; - } - } - - if (associatedDevice != null) { - InputDeviceContext associatedDeviceContext = inputDeviceContexts.get(associatedDevice.getId()); - - // Create a new context for the associated device if one doesn't exist - if (associatedDeviceContext == null) { - associatedDeviceContext = createInputDeviceContextForDevice(associatedDevice); - inputDeviceContexts.put(associatedDevice.getId(), associatedDeviceContext); - } - - // Assign a controller number for the associated device if one isn't assigned - if (!associatedDeviceContext.assignedControllerNumber) { - assignControllerNumberIfNeeded(associatedDeviceContext); - } - - // Propagate the associated controller number - context.controllerNumber = associatedDeviceContext.controllerNumber; - - LimeLog.info("Propagated controller number from "+associatedDeviceContext.name); - } - } - else { - LimeLog.info("Not reserving a controller number"); - context.controllerNumber = 0; - } - - // If the gamepad doesn't have motion sensors, use the on-device sensors as a fallback for player 1 - if (prefConfig.gamepadMotionSensorsFallbackToDevice && context.controllerNumber == 0 && devContext.sensorManager == null) { - devContext.sensorManager = deviceSensorManager; - } - } - else { - if (prefConfig.multiController) { - context.controllerNumber = 0; - - LimeLog.info("Reserving the next available controller number"); - for (short i = 0; i < MAX_GAMEPADS; i++) { - if ((currentControllers & (1 << i)) == 0) { - // Found an unused controller value - currentControllers |= (1 << i); - - // Take this value out of the initial gamepad set - initialControllers &= ~(1 << i); - - context.controllerNumber = i; - context.reservedControllerNumber = true; - break; - } - } - } - else { - LimeLog.info("Not reserving a controller number"); - context.controllerNumber = 0; - } - } - - LimeLog.info("Assigned as controller "+context.controllerNumber); - context.assignedControllerNumber = true; - - // Report attributes of this new controller to the host - context.sendControllerArrival(); - } - - private UsbDeviceContext createUsbDeviceContextForDevice(AbstractController device) { - UsbDeviceContext context = new UsbDeviceContext(); - - context.id = device.getControllerId(); - context.device = device; - context.external = true; - - context.vendorId = device.getVendorId(); - context.productId = device.getProductId(); - - context.leftStickDeadzoneRadius = (float) stickDeadzone; - context.rightStickDeadzoneRadius = (float) stickDeadzone; - context.triggerDeadzone = 0.13f; - - return context; - } - - private static boolean hasButtonUnderTouchpad(InputDevice dev, byte type) { - // It has to have a touchpad to have a button under it - if ((dev.getSources() & InputDevice.SOURCE_TOUCHPAD) != InputDevice.SOURCE_TOUCHPAD) { - return false; - } - - // Landroid/view/InputDevice;->hasButtonUnderPad()Z is blocked after O - if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.O) { - try { - return (Boolean) dev.getClass().getMethod("hasButtonUnderPad").invoke(dev); - } catch (NoSuchMethodException e) { - e.printStackTrace(); - } catch (IllegalAccessException e) { - e.printStackTrace(); - } catch (InvocationTargetException e) { - e.printStackTrace(); - } catch (ClassCastException e) { - e.printStackTrace(); - } - } - - // We can't use the platform API, so we'll have to just guess based on the gamepad type. - // If this is a PlayStation controller with a touchpad, we know it has a clickpad. - return type == MoonBridge.LI_CTYPE_PS; - } - - private static boolean isExternal(InputDevice dev) { - // The ASUS Tinker Board inaccurately reports Bluetooth gamepads as internal, - // causing shouldIgnoreBack() to believe it should pass through back as a - // navigation event for any attached gamepads. - if (Build.MODEL.equals("Tinker Board")) { - return true; - } - - String deviceName = dev.getName(); - if (deviceName.contains("gpio") || // This is the back button on Shield portable consoles - deviceName.contains("joy_key") || // These are the gamepad buttons on the Archos Gamepad 2 - deviceName.contains("keypad") || // These are gamepad buttons on the XPERIA Play - deviceName.equalsIgnoreCase("NVIDIA Corporation NVIDIA Controller v01.01") || // Gamepad on Shield Portable - deviceName.equalsIgnoreCase("NVIDIA Corporation NVIDIA Controller v01.02") || // Gamepad on Shield Portable (?) - deviceName.equalsIgnoreCase("GR0006") // Gamepad on Logitech G Cloud - ) - { - LimeLog.info(dev.getName()+" is internal by hardcoded mapping"); - return false; - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - // Landroid/view/InputDevice;->isExternal()Z is officially public on Android Q - return dev.isExternal(); - } - else { - try { - // Landroid/view/InputDevice;->isExternal()Z is on the light graylist in Android P - return (Boolean)dev.getClass().getMethod("isExternal").invoke(dev); - } catch (NoSuchMethodException e) { - e.printStackTrace(); - } catch (IllegalAccessException e) { - e.printStackTrace(); - } catch (InvocationTargetException e) { - e.printStackTrace(); - } catch (ClassCastException e) { - e.printStackTrace(); - } - } - - // Answer true if we don't know - return true; - } - - private boolean shouldIgnoreBack(InputDevice dev) { - String devName = dev.getName(); - - // The Serval has a Select button but the framework doesn't - // know about that because it uses a non-standard scancode. - if (devName.contains("Razer Serval")) { - return true; - } - - // Classify this device as a remote by name if it has no joystick axes - if (!hasJoystickAxes(dev) && devName.toLowerCase().contains("remote")) { - return true; - } - - // Otherwise, dynamically try to determine whether we should allow this - // back button to function for navigation. - // - // First, check if this is an internal device we're being called on. - if (!isExternal(dev)) { - InputManager im = (InputManager) activityContext.getSystemService(Context.INPUT_SERVICE); - - boolean foundInternalGamepad = false; - boolean foundInternalSelect = false; - for (int id : im.getInputDeviceIds()) { - InputDevice currentDev = im.getInputDevice(id); - - // Ignore external devices - if (currentDev == null || isExternal(currentDev)) { - continue; - } - - // Note that we are explicitly NOT excluding the current device we're examining here, - // since the other gamepad buttons may be on our current device and that's fine. - if (currentDev.hasKeys(KeyEvent.KEYCODE_BUTTON_SELECT)[0]) { - foundInternalSelect = true; - } - - // We don't check KEYCODE_BUTTON_A here, since the Shield Android TV has a - // virtual mouse device that claims to have KEYCODE_BUTTON_A. Instead, we rely - // on the SOURCE_GAMEPAD flag to be set on gamepad devices. - if (hasGamepadButtons(currentDev)) { - foundInternalGamepad = true; - } - } - - // Allow the back button to function for navigation if we either: - // a) have no internal gamepad (most phones) - // b) have an internal gamepad but also have an internal select button (GPD XD) - // but not: - // c) have an internal gamepad but no internal select button (NVIDIA SHIELD Portable) - return !foundInternalGamepad || foundInternalSelect; - } - else { - // For external devices, we want to pass through the back button if the device - // has no gamepad axes or gamepad buttons. - return !hasJoystickAxes(dev) && !hasGamepadButtons(dev); - } - } - - private InputDeviceContext createInputDeviceContextForDevice(InputDevice dev) { - InputDeviceContext context = new InputDeviceContext(); - String devName = dev.getName(); - - LimeLog.info("Creating controller context for device: "+devName); - LimeLog.info("Vendor ID: " + dev.getVendorId()); - LimeLog.info("Product ID: "+dev.getProductId()); - LimeLog.info(dev.toString()); - - context.inputDevice = dev; - context.name = devName; - context.id = dev.getId(); - context.external = isExternal(dev); - - context.vendorId = dev.getVendorId(); - context.productId = dev.getProductId(); - - // These aren't always present in the Android key layout files, so they won't show up - // in our normal InputDevice.hasKeys() probing. - context.hasPaddles = MoonBridge.guessControllerHasPaddles(context.vendorId, context.productId); - context.hasShare = MoonBridge.guessControllerHasShareButton(context.vendorId, context.productId); - - // Try to use the InputDevice's associated vibrators first - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && hasQuadAmplitudeControlledRumbleVibrators(dev.getVibratorManager())) { - context.vibratorManager = dev.getVibratorManager(); - context.quadVibrators = true; - } - else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && hasDualAmplitudeControlledRumbleVibrators(dev.getVibratorManager())) { - context.vibratorManager = dev.getVibratorManager(); - context.quadVibrators = false; - } - else if (dev.getVibrator().hasVibrator()) { - context.vibrator = dev.getVibrator(); - } - else if (!context.external) { - // If this is an internal controller, try to use the device's vibrator - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && hasQuadAmplitudeControlledRumbleVibrators(deviceVibratorManager)) { - context.vibratorManager = deviceVibratorManager; - context.quadVibrators = true; - } - else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && hasDualAmplitudeControlledRumbleVibrators(deviceVibratorManager)) { - context.vibratorManager = deviceVibratorManager; - context.quadVibrators = false; - } - else if (deviceVibrator.hasVibrator()) { - context.vibrator = deviceVibrator; - } - } - - // On Android 12, we can try to use the InputDevice's sensors. This may not work if the - // Linux kernel version doesn't have motion sensor support, which is common for third-party - // gamepads. - // - // Android 12 has a bug that causes InputDeviceSensorManager to cause a NPE on a background - // thread due to bad error checking in InputListener callbacks. InputDeviceSensorManager is - // created upon the first call to InputDevice.getSensorManager(), so we avoid calling this - // on Android 12 unless we have a gamepad that could plausibly have motion sensors. - // https://cs.android.com/android/_/android/platform/frameworks/base/+/8970010a5e9f3dc5c069f56b4147552accfcbbeb - if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU || - (Build.VERSION.SDK_INT == Build.VERSION_CODES.S && - (context.vendorId == 0x054c || context.vendorId == 0x057e))) && // Sony or Nintendo - prefConfig.gamepadMotionSensors) { - if (dev.getSensorManager().getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null || dev.getSensorManager().getDefaultSensor(Sensor.TYPE_GYROSCOPE) != null) { - context.sensorManager = dev.getSensorManager(); - } - } - - // Check if this device has a usable RGB LED and cache that result - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - for (Light light : dev.getLightsManager().getLights()) { - if (light.hasRgbControl()) { - context.hasRgbLed = true; - break; - } - } - } - - // Detect if the gamepad has Mode and Select buttons according to the Android key layouts. - // We do this first because other codepaths below may override these defaults. - boolean[] buttons = dev.hasKeys(KeyEvent.KEYCODE_BUTTON_MODE, KeyEvent.KEYCODE_BUTTON_SELECT, KeyEvent.KEYCODE_BACK, 0); - context.hasMode = buttons[0]; - context.hasSelect = buttons[1] || buttons[2]; - - context.touchpadXRange = dev.getMotionRange(MotionEvent.AXIS_X, InputDevice.SOURCE_TOUCHPAD); - context.touchpadYRange = dev.getMotionRange(MotionEvent.AXIS_Y, InputDevice.SOURCE_TOUCHPAD); - context.touchpadPressureRange = dev.getMotionRange(MotionEvent.AXIS_PRESSURE, InputDevice.SOURCE_TOUCHPAD); - - context.leftStickXAxis = MotionEvent.AXIS_X; - context.leftStickYAxis = MotionEvent.AXIS_Y; - if (getMotionRangeForJoystickAxis(dev, context.leftStickXAxis) != null && - getMotionRangeForJoystickAxis(dev, context.leftStickYAxis) != null) { - // This is a gamepad - hasGameController = true; - context.hasJoystickAxes = true; - } - - // This is hack to deal with the Nvidia Shield's modifications that causes the DS4 clickpad - // to work as a duplicate Select button instead of a unique button we can handle separately. - context.isDualShockStandaloneTouchpad = - context.vendorId == 0x054c && // Sony - devName.endsWith(" Touchpad") && - dev.getSources() == (InputDevice.SOURCE_KEYBOARD | InputDevice.SOURCE_MOUSE); - - InputDevice.MotionRange leftTriggerRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_LTRIGGER); - InputDevice.MotionRange rightTriggerRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_RTRIGGER); - InputDevice.MotionRange brakeRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_BRAKE); - InputDevice.MotionRange gasRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_GAS); - InputDevice.MotionRange throttleRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_THROTTLE); - if (leftTriggerRange != null && rightTriggerRange != null) - { - // Some controllers use LTRIGGER and RTRIGGER (like Ouya) - context.leftTriggerAxis = MotionEvent.AXIS_LTRIGGER; - context.rightTriggerAxis = MotionEvent.AXIS_RTRIGGER; - } - else if (brakeRange != null && gasRange != null) - { - // Others use GAS and BRAKE (like Moga) - context.leftTriggerAxis = MotionEvent.AXIS_BRAKE; - context.rightTriggerAxis = MotionEvent.AXIS_GAS; - } - else if (brakeRange != null && throttleRange != null) - { - // Others use THROTTLE and BRAKE (like Xiaomi) - context.leftTriggerAxis = MotionEvent.AXIS_BRAKE; - context.rightTriggerAxis = MotionEvent.AXIS_THROTTLE; - } - else - { - InputDevice.MotionRange rxRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_RX); - InputDevice.MotionRange ryRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_RY); - if (rxRange != null && ryRange != null && devName != null) { - if (dev.getVendorId() == 0x054c) { // Sony - if (dev.hasKeys(KeyEvent.KEYCODE_BUTTON_C)[0]) { - LimeLog.info("Detected non-standard DualShock 4 mapping"); - context.isNonStandardDualShock4 = true; - } else { - LimeLog.info("Detected DualShock 4 (Linux standard mapping)"); - context.usesLinuxGamepadStandardFaceButtons = true; - } - } - - if (context.isNonStandardDualShock4) { - // The old DS4 driver uses RX and RY for triggers - context.leftTriggerAxis = MotionEvent.AXIS_RX; - context.rightTriggerAxis = MotionEvent.AXIS_RY; - - // DS4 has Select and Mode buttons (possibly mapped non-standard) - context.hasSelect = true; - context.hasMode = true; - } - else { - // If it's not a non-standard DS4 controller, it's probably an Xbox controller or - // other sane controller that uses RX and RY for right stick and Z and RZ for triggers. - context.rightStickXAxis = MotionEvent.AXIS_RX; - context.rightStickYAxis = MotionEvent.AXIS_RY; - - // While it's likely that Z and RZ are triggers, we may have digital trigger buttons - // instead. We must check that we actually have Z and RZ axes before assigning them. - if (getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_Z) != null && - getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_RZ) != null) { - context.leftTriggerAxis = MotionEvent.AXIS_Z; - context.rightTriggerAxis = MotionEvent.AXIS_RZ; - } - } - - // Triggers always idle negative on axes that are centered at zero - context.triggersIdleNegative = true; - } - } - - if (context.rightStickXAxis == -1 && context.rightStickYAxis == -1) { - InputDevice.MotionRange zRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_Z); - InputDevice.MotionRange rzRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_RZ); - - // Most other controllers use Z and RZ for the right stick - if (zRange != null && rzRange != null) { - context.rightStickXAxis = MotionEvent.AXIS_Z; - context.rightStickYAxis = MotionEvent.AXIS_RZ; - } - else { - InputDevice.MotionRange rxRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_RX); - InputDevice.MotionRange ryRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_RY); - - // Try RX and RY now - if (rxRange != null && ryRange != null) { - context.rightStickXAxis = MotionEvent.AXIS_RX; - context.rightStickYAxis = MotionEvent.AXIS_RY; - } - } - } - - // Some devices have "hats" for d-pads - InputDevice.MotionRange hatXRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_HAT_X); - InputDevice.MotionRange hatYRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_HAT_Y); - if (hatXRange != null && hatYRange != null) { - context.hatXAxis = MotionEvent.AXIS_HAT_X; - context.hatYAxis = MotionEvent.AXIS_HAT_Y; - } - - if (context.leftStickXAxis != -1 && context.leftStickYAxis != -1) { - context.leftStickDeadzoneRadius = (float) stickDeadzone; - } - - if (context.rightStickXAxis != -1 && context.rightStickYAxis != -1) { - context.rightStickDeadzoneRadius = (float) stickDeadzone; - } - - if (context.leftTriggerAxis != -1 && context.rightTriggerAxis != -1) { - InputDevice.MotionRange ltRange = getMotionRangeForJoystickAxis(dev, context.leftTriggerAxis); - InputDevice.MotionRange rtRange = getMotionRangeForJoystickAxis(dev, context.rightTriggerAxis); - - // It's important to have a valid deadzone so controller packet batching works properly - context.triggerDeadzone = Math.max(Math.abs(ltRange.getFlat()), Math.abs(rtRange.getFlat())); - - // For triggers without (valid) deadzones, we'll use 13% (around XInput's default) - if (context.triggerDeadzone < 0.13f || - context.triggerDeadzone > 0.30f) - { - context.triggerDeadzone = 0.13f; - } - } - - // The ADT-1 controller needs a similar fixup to the ASUS Gamepad - if (dev.getVendorId() == 0x18d1 && dev.getProductId() == 0x2c40) { - context.backIsStart = true; - context.modeIsSelect = true; - context.triggerDeadzone = 0.30f; - context.hasSelect = true; - context.hasMode = false; - } - - context.ignoreBack = shouldIgnoreBack(dev); - - if (devName != null) { - // For the Nexus Player (and probably other ATV devices), we should - // use the back button as start since it doesn't have a start/menu button - // on the controller - if (devName.contains("ASUS Gamepad")) { - boolean[] hasStartKey = dev.hasKeys(KeyEvent.KEYCODE_BUTTON_START, KeyEvent.KEYCODE_MENU, 0); - if (!hasStartKey[0] && !hasStartKey[1]) { - context.backIsStart = true; - context.modeIsSelect = true; - context.hasSelect = true; - context.hasMode = false; - } - - // The ASUS Gamepad has triggers that sit far forward and are prone to false presses - // so we increase the deadzone on them to minimize this - context.triggerDeadzone = 0.30f; - } - // SHIELD controllers will use small stick deadzones - else if (devName.contains("SHIELD") || devName.contains("NVIDIA Controller")) { - // The big Nvidia button on the Shield controllers acts like a Search button. It - // summons the Google Assistant on the Shield TV. On my Pixel 4, it seems to do - // nothing, so we can hijack it to act like a mode button. - if (devName.contains("NVIDIA Controller v01.03") || devName.contains("NVIDIA Controller v01.04")) { - context.searchIsMode = true; - context.hasMode = true; - } - } - // The Serval has a couple of unknown buttons that are start and select. It also has - // a back button which we want to ignore since there's already a select button. - else if (devName.contains("Razer Serval")) { - context.isServal = true; - - // Serval has Select and Mode buttons (possibly mapped non-standard) - context.hasMode = true; - context.hasSelect = true; - } - // The Xbox One S Bluetooth controller has some mappings that need fixing up. - // However, Microsoft released a firmware update with no change to VID/PID - // or device name that fixed the mappings for Android. Since there's - // no good way to detect this, we'll use the presence of GAS/BRAKE axes - // that were added in the latest firmware. If those are present, the only - // required fixup is ignoring the select button. - else if (devName.equals("Xbox Wireless Controller")) { - if (gasRange == null) { - context.isNonStandardXboxBtController = true; - - // Xbox One S has Select and Mode buttons (possibly mapped non-standard) - context.hasMode = true; - context.hasSelect = true; - } - } - } - - // Thrustmaster Score A gamepad home button reports directly to android as - // KEY_HOMEPAGE event on another event channel - if (dev.getVendorId() == 0x044f && dev.getProductId() == 0xb328) { - context.hasMode = false; - } - - LimeLog.info("Analog stick deadzone: "+context.leftStickDeadzoneRadius+" "+context.rightStickDeadzoneRadius); - LimeLog.info("Trigger deadzone: "+context.triggerDeadzone); - - return context; - } - - private InputDeviceContext getContextForEvent(InputEvent event) { - // Don't return a context if we're stopped - if (stopped) { - return null; - } - else if (event.getDeviceId() == 0) { - // Unknown devices use the default context - return defaultContext; - } - else if (event.getDevice() == null) { - // During device removal, sometimes we can get events after the - // input device has been destroyed. In this case we'll see a - // != 0 device ID but no device attached. - return null; - } - - // HACK for https://issuetracker.google.com/issues/163120692 - if (Build.VERSION.SDK_INT == Build.VERSION_CODES.R) { - if (event.getDeviceId() == -1) { - return defaultContext; - } - } - - // Return the existing context if it exists - InputDeviceContext context = inputDeviceContexts.get(event.getDeviceId()); - if (context != null) { - return context; - } - - // Otherwise create a new context - context = createInputDeviceContextForDevice(event.getDevice()); - inputDeviceContexts.put(event.getDeviceId(), context); - - return context; - } - - private byte maxByMagnitude(byte a, byte b) { - int absA = Math.abs(a); - int absB = Math.abs(b); - if (absA > absB) { - return a; - } - else { - return b; - } - } - - private short maxByMagnitude(short a, short b) { - int absA = Math.abs(a); - int absB = Math.abs(b); - if (absA > absB) { - return a; - } - else { - return b; - } - } - - private short getActiveControllerMask() { - if (prefConfig.multiController) { - return (short)(currentControllers | initialControllers | (prefConfig.onscreenController ? 1 : 0)); - } - else { - // Only Player 1 is active with multi-controller disabled - return 1; - } - } - - private static boolean areBatteryCapacitiesEqual(float first, float second) { - // With no NaNs involved, it is a simple equality comparison. - if (!Float.isNaN(first) && !Float.isNaN(second)) { - return first == second; - } - else { - // If we have a NaN in one or both positions, compare NaN-ness instead. - // Equality comparisons will always return false for NaN. - return Float.isNaN(first) == Float.isNaN(second); - } - } - - // This must not be called on the main thread due to risk of ANRs! - private void sendControllerBatteryPacket(InputDeviceContext context) { - int currentBatteryStatus; - float currentBatteryCapacity; - - // Use the BatteryState object introduced in Android S, if it's available and present. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && context.inputDevice.getBatteryState().isPresent()) { - currentBatteryStatus = context.inputDevice.getBatteryState().getStatus(); - currentBatteryCapacity = context.inputDevice.getBatteryState().getCapacity(); - } - else if (sceManager.isRecognizedDevice(context.inputDevice)) { - // On the SHIELD Android TV, we can use a proprietary API to access battery/charge state. - // We will convert it to the same form used by BatteryState to share code. - int batteryPercentage = sceManager.getBatteryPercentage(context.inputDevice); - if (batteryPercentage < 0) { - currentBatteryCapacity = Float.NaN; - } - else { - currentBatteryCapacity = batteryPercentage / 100.f; - } - - SceConnectionType connectionType = sceManager.getConnectionType(context.inputDevice); - SceChargingState chargingState = sceManager.getChargingState(context.inputDevice); - - // We can make some assumptions about charge state based on the connection type - if (connectionType == SceConnectionType.WIRED || connectionType == SceConnectionType.BOTH) { - if (batteryPercentage == 100) { - currentBatteryStatus = BatteryState.STATUS_FULL; - } - else if (chargingState == SceChargingState.NOT_CHARGING) { - currentBatteryStatus = BatteryState.STATUS_NOT_CHARGING; - } - else { - currentBatteryStatus = BatteryState.STATUS_CHARGING; - } - } - else if (connectionType == SceConnectionType.WIRELESS) { - if (chargingState == SceChargingState.CHARGING) { - currentBatteryStatus = BatteryState.STATUS_CHARGING; - } - else { - currentBatteryStatus = BatteryState.STATUS_DISCHARGING; - } - } - else { - // If connection type is unknown, just use the charge state - if (batteryPercentage == 100) { - currentBatteryStatus = BatteryState.STATUS_FULL; - } - else if (chargingState == SceChargingState.NOT_CHARGING) { - currentBatteryStatus = BatteryState.STATUS_DISCHARGING; - } - else if (chargingState == SceChargingState.CHARGING) { - currentBatteryStatus = BatteryState.STATUS_CHARGING; - } - else { - currentBatteryStatus = BatteryState.STATUS_UNKNOWN; - } - } - } - else { - return; - } - - if (currentBatteryStatus != context.lastReportedBatteryStatus || - !areBatteryCapacitiesEqual(currentBatteryCapacity, context.lastReportedBatteryCapacity)) { - byte state; - byte percentage; - - switch (currentBatteryStatus) { - case BatteryState.STATUS_UNKNOWN: - state = MoonBridge.LI_BATTERY_STATE_UNKNOWN; - break; - - case BatteryState.STATUS_CHARGING: - state = MoonBridge.LI_BATTERY_STATE_CHARGING; - break; - - case BatteryState.STATUS_DISCHARGING: - state = MoonBridge.LI_BATTERY_STATE_DISCHARGING; - break; - - case BatteryState.STATUS_NOT_CHARGING: - state = MoonBridge.LI_BATTERY_STATE_NOT_CHARGING; - break; - - case BatteryState.STATUS_FULL: - state = MoonBridge.LI_BATTERY_STATE_FULL; - break; - - default: - return; - } - - if (Float.isNaN(currentBatteryCapacity)) { - percentage = MoonBridge.LI_BATTERY_PERCENTAGE_UNKNOWN; - } - else { - percentage = (byte)(currentBatteryCapacity * 100); - } - - conn.sendControllerBatteryEvent((byte)context.controllerNumber, state, percentage); - - context.lastReportedBatteryStatus = currentBatteryStatus; - context.lastReportedBatteryCapacity = currentBatteryCapacity; - } - } - - private void sendControllerInputPacket(GenericControllerContext originalContext) { - assignControllerNumberIfNeeded(originalContext); - - // Take the context's controller number and fuse all inputs with the same number - short controllerNumber = originalContext.controllerNumber; - int inputMap = 0; - byte leftTrigger = 0; - byte rightTrigger = 0; - short leftStickX = 0; - short leftStickY = 0; - short rightStickX = 0; - short rightStickY = 0; - - // In order to properly handle controllers that are split into multiple devices, - // we must aggregate all controllers with the same controller number into a single - // device before we send it. - for (int i = 0; i < inputDeviceContexts.size(); i++) { - GenericControllerContext context = inputDeviceContexts.valueAt(i); - if (context.assignedControllerNumber && - context.controllerNumber == controllerNumber && - context.mouseEmulationActive == originalContext.mouseEmulationActive) { - inputMap |= context.inputMap; - leftTrigger |= maxByMagnitude(leftTrigger, context.leftTrigger); - rightTrigger |= maxByMagnitude(rightTrigger, context.rightTrigger); - leftStickX |= maxByMagnitude(leftStickX, context.leftStickX); - leftStickY |= maxByMagnitude(leftStickY, context.leftStickY); - rightStickX |= maxByMagnitude(rightStickX, context.rightStickX); - rightStickY |= maxByMagnitude(rightStickY, context.rightStickY); - } - } - for (int i = 0; i < usbDeviceContexts.size(); i++) { - GenericControllerContext context = usbDeviceContexts.valueAt(i); - if (context.assignedControllerNumber && - context.controllerNumber == controllerNumber && - context.mouseEmulationActive == originalContext.mouseEmulationActive) { - inputMap |= context.inputMap; - leftTrigger |= maxByMagnitude(leftTrigger, context.leftTrigger); - rightTrigger |= maxByMagnitude(rightTrigger, context.rightTrigger); - leftStickX |= maxByMagnitude(leftStickX, context.leftStickX); - leftStickY |= maxByMagnitude(leftStickY, context.leftStickY); - rightStickX |= maxByMagnitude(rightStickX, context.rightStickX); - rightStickY |= maxByMagnitude(rightStickY, context.rightStickY); - } - } - if (defaultContext.controllerNumber == controllerNumber) { - inputMap |= defaultContext.inputMap; - leftTrigger |= maxByMagnitude(leftTrigger, defaultContext.leftTrigger); - rightTrigger |= maxByMagnitude(rightTrigger, defaultContext.rightTrigger); - leftStickX |= maxByMagnitude(leftStickX, defaultContext.leftStickX); - leftStickY |= maxByMagnitude(leftStickY, defaultContext.leftStickY); - rightStickX |= maxByMagnitude(rightStickX, defaultContext.rightStickX); - rightStickY |= maxByMagnitude(rightStickY, defaultContext.rightStickY); - } - - if (originalContext.mouseEmulationActive) { - int changedMask = inputMap ^ originalContext.mouseEmulationLastInputMap; - - boolean aDown = (inputMap & ControllerPacket.A_FLAG) != 0; - boolean bDown = (inputMap & ControllerPacket.B_FLAG) != 0; - - originalContext.mouseEmulationLastInputMap = inputMap; - - if ((changedMask & ControllerPacket.A_FLAG) != 0) { - if (aDown) { - conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_LEFT); - } - else { - conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT); - } - } - if ((changedMask & ControllerPacket.B_FLAG) != 0) { - if (bDown) { - conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_RIGHT); - } - else { - conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_RIGHT); - } - } - if ((changedMask & ControllerPacket.UP_FLAG) != 0) { - if ((inputMap & ControllerPacket.UP_FLAG) != 0) { - conn.sendMouseScroll((byte) 1); - } - } - if ((changedMask & ControllerPacket.DOWN_FLAG) != 0) { - if ((inputMap & ControllerPacket.DOWN_FLAG) != 0) { - conn.sendMouseScroll((byte) -1); - } - } - if ((changedMask & ControllerPacket.RIGHT_FLAG) != 0) { - if ((inputMap & ControllerPacket.RIGHT_FLAG) != 0) { - conn.sendMouseHScroll((byte) 1); - } - } - if ((changedMask & ControllerPacket.LEFT_FLAG) != 0) { - if ((inputMap & ControllerPacket.LEFT_FLAG) != 0) { - conn.sendMouseHScroll((byte) -1); - } - } - - conn.sendControllerInput(controllerNumber, getActiveControllerMask(), - (short)0, (byte)0, (byte)0, (short)0, (short)0, (short)0, (short)0); - } - else { - conn.sendControllerInput(controllerNumber, getActiveControllerMask(), - inputMap, - leftTrigger, rightTrigger, - leftStickX, leftStickY, - rightStickX, rightStickY); - } - } - - private final int REMAP_IGNORE = -1; - private final int REMAP_CONSUME = -2; - - // Return a valid keycode, -2 to consume, or -1 to not consume the event - // Device MAY BE NULL - private int handleRemapping(InputDeviceContext context, KeyEvent event) { - // Don't capture the back button if configured - if (context.ignoreBack) { - if (event.getKeyCode() == KeyEvent.KEYCODE_BACK) { - return REMAP_IGNORE; - } - } - - // If we know this gamepad has a share button and receive an unmapped - // KEY_RECORD event, report that as a share button press. - if (context.hasShare) { - if (event.getKeyCode() == KeyEvent.KEYCODE_UNKNOWN && - event.getScanCode() == 167) { - return KeyEvent.KEYCODE_MEDIA_RECORD; - } - } - - // The Shield's key layout files map the DualShock 4 clickpad button to - // BUTTON_SELECT instead of something sane like BUTTON_1 as the standard AOSP - // mapping does. If we get a button from a Sony device reported as BUTTON_SELECT - // that matches the keycode used by hid-sony for the clickpad or it's from the - // separate touchpad input device, remap it to BUTTON_1 to match the current AOSP - // layout and trigger our touchpad button logic. - if (context.vendorId == 0x054c && - event.getKeyCode() == KeyEvent.KEYCODE_BUTTON_SELECT && - (event.getScanCode() == 317 || context.isDualShockStandaloneTouchpad)) { - return KeyEvent.KEYCODE_BUTTON_1; - } - - // Override mode button for 8BitDo controllers - if (context.vendorId == 0x2dc8 && event.getScanCode() == 306) { - return KeyEvent.KEYCODE_BUTTON_MODE; - } - - // This mapping was adding in Android 10, then changed based on - // kernel changes (adding hid-nintendo) in Android 11. If we're - // on anything newer than Pie, just use the built-in mapping. - if ((context.vendorId == 0x057e && context.productId == 0x2009 && Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) || // Switch Pro controller - (context.vendorId == 0x0f0d && context.productId == 0x00c1)) { // HORIPAD for Switch - switch (event.getScanCode()) { - case 0x130: - return KeyEvent.KEYCODE_BUTTON_A; - case 0x131: - return KeyEvent.KEYCODE_BUTTON_B; - case 0x132: - return KeyEvent.KEYCODE_BUTTON_X; - case 0x133: - return KeyEvent.KEYCODE_BUTTON_Y; - case 0x134: - return KeyEvent.KEYCODE_BUTTON_L1; - case 0x135: - return KeyEvent.KEYCODE_BUTTON_R1; - case 0x136: - return KeyEvent.KEYCODE_BUTTON_L2; - case 0x137: - return KeyEvent.KEYCODE_BUTTON_R2; - case 0x138: - return KeyEvent.KEYCODE_BUTTON_SELECT; - case 0x139: - return KeyEvent.KEYCODE_BUTTON_START; - case 0x13A: - return KeyEvent.KEYCODE_BUTTON_THUMBL; - case 0x13B: - return KeyEvent.KEYCODE_BUTTON_THUMBR; - case 0x13D: - return KeyEvent.KEYCODE_BUTTON_MODE; - } - } - - if (context.usesLinuxGamepadStandardFaceButtons) { - // Android's Generic.kl swaps BTN_NORTH and BTN_WEST - switch (event.getScanCode()) { - case 304: - return KeyEvent.KEYCODE_BUTTON_A; - case 305: - return KeyEvent.KEYCODE_BUTTON_B; - case 307: - return KeyEvent.KEYCODE_BUTTON_Y; - case 308: - return KeyEvent.KEYCODE_BUTTON_X; - } - } - - if (context.isNonStandardDualShock4) { - switch (event.getScanCode()) { - case 304: - return KeyEvent.KEYCODE_BUTTON_X; - case 305: - return KeyEvent.KEYCODE_BUTTON_A; - case 306: - return KeyEvent.KEYCODE_BUTTON_B; - case 307: - return KeyEvent.KEYCODE_BUTTON_Y; - case 308: - return KeyEvent.KEYCODE_BUTTON_L1; - case 309: - return KeyEvent.KEYCODE_BUTTON_R1; - /* - **** Using analog triggers instead **** - case 310: - return KeyEvent.KEYCODE_BUTTON_L2; - case 311: - return KeyEvent.KEYCODE_BUTTON_R2; - */ - case 312: - return KeyEvent.KEYCODE_BUTTON_SELECT; - case 313: - return KeyEvent.KEYCODE_BUTTON_START; - case 314: - return KeyEvent.KEYCODE_BUTTON_THUMBL; - case 315: - return KeyEvent.KEYCODE_BUTTON_THUMBR; - case 316: - return KeyEvent.KEYCODE_BUTTON_MODE; - default: - return REMAP_CONSUME; - } - } - // If this is a Serval controller sending an unknown key code, it's probably - // the start and select buttons - else if (context.isServal && event.getKeyCode() == KeyEvent.KEYCODE_UNKNOWN) { - switch (event.getScanCode()) { - case 314: - return KeyEvent.KEYCODE_BUTTON_SELECT; - case 315: - return KeyEvent.KEYCODE_BUTTON_START; - } - } - else if (context.isNonStandardXboxBtController) { - switch (event.getScanCode()) { - case 306: - return KeyEvent.KEYCODE_BUTTON_X; - case 307: - return KeyEvent.KEYCODE_BUTTON_Y; - case 308: - return KeyEvent.KEYCODE_BUTTON_L1; - case 309: - return KeyEvent.KEYCODE_BUTTON_R1; - case 310: - return KeyEvent.KEYCODE_BUTTON_SELECT; - case 311: - return KeyEvent.KEYCODE_BUTTON_START; - case 312: - return KeyEvent.KEYCODE_BUTTON_THUMBL; - case 313: - return KeyEvent.KEYCODE_BUTTON_THUMBR; - case 139: - return KeyEvent.KEYCODE_BUTTON_MODE; - default: - // Other buttons are mapped correctly - } - - // The Xbox button is sent as MENU - if (event.getKeyCode() == KeyEvent.KEYCODE_MENU) { - return KeyEvent.KEYCODE_BUTTON_MODE; - } - } - else if (context.vendorId == 0x0b05 && // ASUS - (context.productId == 0x7900 || // Kunai - USB - context.productId == 0x7902)) // Kunai - Bluetooth - { - // ROG Kunai has special M1-M4 buttons that are accessible via the - // joycon-style detachable controllers that we should map to Start - // and Select. - switch (event.getScanCode()) { - case 264: - case 266: - return KeyEvent.KEYCODE_BUTTON_START; - - case 265: - case 267: - return KeyEvent.KEYCODE_BUTTON_SELECT; - } - } - - if (context.hatXAxis == -1 && - context.hatYAxis == -1 && - /* FIXME: There's no good way to know for sure if xpad is bound - to this device, so we won't use the name to validate if these - scancodes should be mapped to DPAD - - context.isXboxController && - */ - event.getKeyCode() == KeyEvent.KEYCODE_UNKNOWN) { - // If there's not a proper Xbox controller mapping, we'll translate the raw d-pad - // scan codes into proper key codes - switch (event.getScanCode()) - { - case 704: - return KeyEvent.KEYCODE_DPAD_LEFT; - case 705: - return KeyEvent.KEYCODE_DPAD_RIGHT; - case 706: - return KeyEvent.KEYCODE_DPAD_UP; - case 707: - return KeyEvent.KEYCODE_DPAD_DOWN; - } - } - - // Past here we can fixup the keycode and potentially trigger - // another special case so we need to remember what keycode we're using - int keyCode = event.getKeyCode(); - - // This is a hack for (at least) the "Tablet Remote" app - // which sends BACK with META_ALT_ON instead of KEYCODE_BUTTON_B - if (keyCode == KeyEvent.KEYCODE_BACK && - !event.hasNoModifiers() && - (event.getFlags() & KeyEvent.FLAG_SOFT_KEYBOARD) != 0) - { - keyCode = KeyEvent.KEYCODE_BUTTON_B; - } - - if (keyCode == KeyEvent.KEYCODE_BUTTON_START || - keyCode == KeyEvent.KEYCODE_MENU) { - // Ensure that we never use back as start if we have a real start - context.backIsStart = false; - } - else if (keyCode == KeyEvent.KEYCODE_BUTTON_SELECT) { - // Don't use mode as select if we have a select - context.modeIsSelect = false; - } - else if (context.backIsStart && keyCode == KeyEvent.KEYCODE_BACK) { - // Emulate the start button with back - return KeyEvent.KEYCODE_BUTTON_START; - } - else if (context.modeIsSelect && keyCode == KeyEvent.KEYCODE_BUTTON_MODE) { - // Emulate the select button with mode - return KeyEvent.KEYCODE_BUTTON_SELECT; - } - else if (context.searchIsMode && keyCode == KeyEvent.KEYCODE_SEARCH) { - // Emulate the mode button with search - return KeyEvent.KEYCODE_BUTTON_MODE; - } - - return keyCode; - } - - private int handleFlipFaceButtons(int keyCode) { - switch (keyCode) { - case KeyEvent.KEYCODE_BUTTON_A: - return KeyEvent.KEYCODE_BUTTON_B; - case KeyEvent.KEYCODE_BUTTON_B: - return KeyEvent.KEYCODE_BUTTON_A; - case KeyEvent.KEYCODE_BUTTON_X: - return KeyEvent.KEYCODE_BUTTON_Y; - case KeyEvent.KEYCODE_BUTTON_Y: - return KeyEvent.KEYCODE_BUTTON_X; - default: - return keyCode; - } - } - - private Vector2d populateCachedVector(float x, float y) { - // Reinitialize our cached Vector2d object - inputVector.initialize(x, y); - return inputVector; - } - - private void handleDeadZone(Vector2d stickVector, float deadzoneRadius) { - if (stickVector.getMagnitude() <= deadzoneRadius) { - // Deadzone - stickVector.initialize(0, 0); - } - - // We're not normalizing here because we let the computer handle the deadzones. - // Normalizing can make the deadzones larger than they should be after the computer also - // evaluates the deadzone. - } - - private void handleAxisSet(InputDeviceContext context, float lsX, float lsY, float rsX, - float rsY, float lt, float rt, float hatX, float hatY) { - - if (context.leftStickXAxis != -1 && context.leftStickYAxis != -1) { - Vector2d leftStickVector = populateCachedVector(lsX, lsY); - - handleDeadZone(leftStickVector, context.leftStickDeadzoneRadius); - - context.leftStickX = (short) (leftStickVector.getX() * 0x7FFE); - context.leftStickY = (short) (-leftStickVector.getY() * 0x7FFE); - } - - if (context.rightStickXAxis != -1 && context.rightStickYAxis != -1) { - Vector2d rightStickVector = populateCachedVector(rsX, rsY); - - handleDeadZone(rightStickVector, context.rightStickDeadzoneRadius); - - context.rightStickX = (short) (rightStickVector.getX() * 0x7FFE); - context.rightStickY = (short) (-rightStickVector.getY() * 0x7FFE); - } - - if (context.leftTriggerAxis != -1 && context.rightTriggerAxis != -1) { - // Android sends an initial 0 value for trigger axes even if the trigger - // should be negative when idle. After the first touch, the axes will go back - // to normal behavior, so ignore triggersIdleNegative for each trigger until - // first touch. - if (lt != 0) { - context.leftTriggerAxisUsed = true; - } - if (rt != 0) { - context.rightTriggerAxisUsed = true; - } - if (context.triggersIdleNegative) { - if (context.leftTriggerAxisUsed) { - lt = (lt + 1) / 2; - } - if (context.rightTriggerAxisUsed) { - rt = (rt + 1) / 2; - } - } - - if (lt <= context.triggerDeadzone) { - lt = 0; - } - if (rt <= context.triggerDeadzone) { - rt = 0; - } - - context.leftTrigger = (byte)(lt * 0xFF); - context.rightTrigger = (byte)(rt * 0xFF); - } - - if (context.hatXAxis != -1 && context.hatYAxis != -1) { - context.inputMap &= ~(ControllerPacket.LEFT_FLAG | ControllerPacket.RIGHT_FLAG); - if (hatX < -0.5) { - context.inputMap |= ControllerPacket.LEFT_FLAG; - context.hatXAxisUsed = true; - } - else if (hatX > 0.5) { - context.inputMap |= ControllerPacket.RIGHT_FLAG; - context.hatXAxisUsed = true; - } - - context.inputMap &= ~(ControllerPacket.UP_FLAG | ControllerPacket.DOWN_FLAG); - if (hatY < -0.5) { - context.inputMap |= ControllerPacket.UP_FLAG; - context.hatYAxisUsed = true; - } - else if (hatY > 0.5) { - context.inputMap |= ControllerPacket.DOWN_FLAG; - context.hatYAxisUsed = true; - } - } - - sendControllerInputPacket(context); - } - - // Normalize the given raw float value into a 0.0-1.0f range - private float normalizeRawValueWithRange(float value, InputDevice.MotionRange range) { - value = Math.max(value, range.getMin()); - value = Math.min(value, range.getMax()); - - value -= range.getMin(); - - return value / range.getRange(); - } - - private boolean sendTouchpadEventForPointer(InputDeviceContext context, MotionEvent event, byte touchType, int pointerIndex) { - float normalizedX = normalizeRawValueWithRange(event.getX(pointerIndex), context.touchpadXRange); - float normalizedY = normalizeRawValueWithRange(event.getY(pointerIndex), context.touchpadYRange); - float normalizedPressure = context.touchpadPressureRange != null ? - normalizeRawValueWithRange(event.getPressure(pointerIndex), context.touchpadPressureRange) - : 0; - - return conn.sendControllerTouchEvent((byte)context.controllerNumber, touchType, - event.getPointerId(pointerIndex), - normalizedX, normalizedY, normalizedPressure) != MoonBridge.LI_ERR_UNSUPPORTED; - } - - public boolean tryHandleTouchpadEvent(MotionEvent event) { - // Bail if this is not a touchpad or mouse event - if (event.getSource() != InputDevice.SOURCE_TOUCHPAD && - event.getSource() != InputDevice.SOURCE_MOUSE) { - return false; - } - - // Only get a context if one already exists. We want to ensure we don't report non-gamepads. - InputDeviceContext context = inputDeviceContexts.get(event.getDeviceId()); - if (context == null) { - return false; - } - - // When we're working with a mouse source instead of a touchpad, we're quite limited in - // what useful input we can provide via the controller API. The ABS_X/ABS_Y values are - // screen coordinates rather than touchpad coordinates. For now, we will just support - // the clickpad button and nothing else. - if (event.getSource() == InputDevice.SOURCE_MOUSE) { - // Unlike the touchpad where down and up refer to individual touches on the touchpad, - // down and up on a mouse indicates the state of the left mouse button. - switch (event.getActionMasked()) { - case MotionEvent.ACTION_DOWN: - context.inputMap |= ControllerPacket.TOUCHPAD_FLAG; - sendControllerInputPacket(context); - break; - case MotionEvent.ACTION_UP: - case MotionEvent.ACTION_CANCEL: - context.inputMap &= ~ControllerPacket.TOUCHPAD_FLAG; - sendControllerInputPacket(context); - break; - default: - break; - } - - return !prefConfig.gamepadTouchpadAsMouse; - } - - byte touchType; - switch (event.getActionMasked()) { - case MotionEvent.ACTION_DOWN: - case MotionEvent.ACTION_POINTER_DOWN: - touchType = MoonBridge.LI_TOUCH_EVENT_DOWN; - break; - - case MotionEvent.ACTION_UP: - case MotionEvent.ACTION_POINTER_UP: - if ((event.getFlags() & MotionEvent.FLAG_CANCELED) != 0) { - touchType = MoonBridge.LI_TOUCH_EVENT_CANCEL; - } - else { - touchType = MoonBridge.LI_TOUCH_EVENT_UP; - } - break; - - case MotionEvent.ACTION_MOVE: - touchType = MoonBridge.LI_TOUCH_EVENT_MOVE; - break; - - case MotionEvent.ACTION_CANCEL: - // ACTION_CANCEL applies to *all* pointers in the gesture, so it maps to CANCEL_ALL - // rather than CANCEL. For a single pointer cancellation, that's indicated via - // FLAG_CANCELED on a ACTION_POINTER_UP. - // https://developer.android.com/develop/ui/views/touch-and-input/gestures/multi - touchType = MoonBridge.LI_TOUCH_EVENT_CANCEL_ALL; - break; - - case MotionEvent.ACTION_BUTTON_PRESS: - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && event.getActionButton() == MotionEvent.BUTTON_PRIMARY) { - context.inputMap |= ControllerPacket.TOUCHPAD_FLAG; - sendControllerInputPacket(context); - return !prefConfig.gamepadTouchpadAsMouse; // Report as unhandled event to trigger mouse handling - } - return false; - - case MotionEvent.ACTION_BUTTON_RELEASE: - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && event.getActionButton() == MotionEvent.BUTTON_PRIMARY) { - context.inputMap &= ~ControllerPacket.TOUCHPAD_FLAG; - sendControllerInputPacket(context); - return !prefConfig.gamepadTouchpadAsMouse; // Report as unhandled event to trigger mouse handling - } - return false; - - default: - return false; - } - - // Bail if the user wants gamepad touchpads to control the mouse - // - // NB: We do this after processing ACTION_BUTTON_PRESS and ACTION_BUTTON_RELEASE - // because we want to still send the touchpad button via the gamepad even when - // configured to use the touchpad for mouse control. - if (prefConfig.gamepadTouchpadAsMouse) { - return false; - } - - // If we don't have X and Y ranges, we can't process this event - if (context.touchpadXRange == null || context.touchpadYRange == null) { - return false; - } - - if (event.getActionMasked() == MotionEvent.ACTION_MOVE) { - // Move events may impact all active pointers - for (int i = 0; i < event.getPointerCount(); i++) { - if (!sendTouchpadEventForPointer(context, event, touchType, i)) { - // Controller touch events are not supported by the host - return false; - } - } - return true; - } - else if (event.getActionMasked() == MotionEvent.ACTION_CANCEL) { - // Cancel impacts all active pointers - return conn.sendControllerTouchEvent((byte)context.controllerNumber, MoonBridge.LI_TOUCH_EVENT_CANCEL_ALL, - 0, 0, 0, 0) != MoonBridge.LI_ERR_UNSUPPORTED; - } - else { - // Down and Up events impact the action index pointer - return sendTouchpadEventForPointer(context, event, touchType, event.getActionIndex()); - } - } - - public boolean handleMotionEvent(MotionEvent event) { - InputDeviceContext context = getContextForEvent(event); - if (context == null) { - return true; - } - - float lsX = 0, lsY = 0, rsX = 0, rsY = 0, rt = 0, lt = 0, hatX = 0, hatY = 0; - - // We purposefully ignore the historical values in the motion event as it makes - // the controller feel sluggish for some users. - - if (context.leftStickXAxis != -1 && context.leftStickYAxis != -1) { - lsX = event.getAxisValue(context.leftStickXAxis); - lsY = event.getAxisValue(context.leftStickYAxis); - } - - if (context.rightStickXAxis != -1 && context.rightStickYAxis != -1) { - rsX = event.getAxisValue(context.rightStickXAxis); - rsY = event.getAxisValue(context.rightStickYAxis); - } - - if (context.leftTriggerAxis != -1 && context.rightTriggerAxis != -1) { - lt = event.getAxisValue(context.leftTriggerAxis); - rt = event.getAxisValue(context.rightTriggerAxis); - } - - if (context.hatXAxis != -1 && context.hatYAxis != -1) { - hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X); - hatY = event.getAxisValue(MotionEvent.AXIS_HAT_Y); - } - - handleAxisSet(context, lsX, lsY, rsX, rsY, lt, rt, hatX, hatY); - - return true; - } - - private Vector2d convertRawStickAxisToPixelMovement(short stickX, short stickY) { - Vector2d vector = new Vector2d(); - vector.initialize(stickX, stickY); - vector.scalarMultiply(1 / 32766.0f); - vector.scalarMultiply(4); - if (vector.getMagnitude() > 0) { - // Move faster as the stick is pressed further from center - vector.scalarMultiply(Math.pow(vector.getMagnitude(), 2)); - } - return vector; - } - - private void sendEmulatedMouseMove(short x, short y) { - Vector2d vector = convertRawStickAxisToPixelMovement(x, y); - if (vector.getMagnitude() >= 1) { - conn.sendMouseMove((short)vector.getX(), (short)-vector.getY()); - } - } - - private void sendEmulatedMouseScroll(short x, short y) { - Vector2d vector = convertRawStickAxisToPixelMovement(x, y); - if (vector.getMagnitude() >= 1) { - conn.sendMouseHighResScroll((short)vector.getY()); - conn.sendMouseHighResHScroll((short)vector.getX()); - } - } - - @TargetApi(31) - private boolean hasDualAmplitudeControlledRumbleVibrators(VibratorManager vm) { - int[] vibratorIds = vm.getVibratorIds(); - - // There must be exactly 2 vibrators on this device - if (vibratorIds.length != 2) { - return false; - } - - // Both vibrators must have amplitude control - for (int vid : vibratorIds) { - if (!vm.getVibrator(vid).hasAmplitudeControl()) { - return false; - } - } - - return true; - } - - // This must only be called if hasDualAmplitudeControlledRumbleVibrators() is true! - @TargetApi(31) - private void rumbleDualVibrators(VibratorManager vm, short lowFreqMotor, short highFreqMotor) { - // Normalize motor values to 0-255 amplitudes for VibrationManager - highFreqMotor = (short)((highFreqMotor >> 8) & 0xFF); - lowFreqMotor = (short)((lowFreqMotor >> 8) & 0xFF); - - // If they're both zero, we can just call cancel(). - if (lowFreqMotor == 0 && highFreqMotor == 0) { - vm.cancel(); - return; - } - - // There's no documentation that states that vibrators for FF_RUMBLE input devices will - // always be enumerated in this order, but it seems consistent between Xbox Series X (USB), - // PS3 (USB), and PS4 (USB+BT) controllers on Android 12 Beta 3. - int[] vibratorIds = vm.getVibratorIds(); - int[] vibratorAmplitudes = new int[] { highFreqMotor, lowFreqMotor }; - - CombinedVibration.ParallelCombination combo = CombinedVibration.startParallel(); - - for (int i = 0; i < vibratorIds.length; i++) { - // It's illegal to create a VibrationEffect with an amplitude of 0. - // Simply excluding that vibrator from our ParallelCombination will turn it off. - if (vibratorAmplitudes[i] != 0) { - combo.addVibrator(vibratorIds[i], VibrationEffect.createOneShot(60000, vibratorAmplitudes[i])); - } - } - - VibrationAttributes.Builder vibrationAttributes = new VibrationAttributes.Builder(); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - vibrationAttributes.setUsage(VibrationAttributes.USAGE_MEDIA); - } - - vm.vibrate(combo.combine(), vibrationAttributes.build()); - } - - @TargetApi(31) - private boolean hasQuadAmplitudeControlledRumbleVibrators(VibratorManager vm) { - int[] vibratorIds = vm.getVibratorIds(); - - // There must be exactly 4 vibrators on this device - if (vibratorIds.length != 4) { - return false; - } - - // All vibrators must have amplitude control - for (int vid : vibratorIds) { - if (!vm.getVibrator(vid).hasAmplitudeControl()) { - return false; - } - } - - return true; - } - - // This must only be called if hasQuadAmplitudeControlledRumbleVibrators() is true! - @TargetApi(31) - private void rumbleQuadVibrators(VibratorManager vm, short lowFreqMotor, short highFreqMotor, short leftTrigger, short rightTrigger) { - // Normalize motor values to 0-255 amplitudes for VibrationManager - highFreqMotor = (short)((highFreqMotor >> 8) & 0xFF); - lowFreqMotor = (short)((lowFreqMotor >> 8) & 0xFF); - leftTrigger = (short)((leftTrigger >> 8) & 0xFF); - rightTrigger = (short)((rightTrigger >> 8) & 0xFF); - - // If they're all zero, we can just call cancel(). - if (lowFreqMotor == 0 && highFreqMotor == 0 && leftTrigger == 0 && rightTrigger == 0) { - vm.cancel(); - return; - } - - // This is a guess based upon the behavior of FF_RUMBLE, but untested due to lack of Linux - // support for trigger rumble! - int[] vibratorIds = vm.getVibratorIds(); - int[] vibratorAmplitudes = new int[] { highFreqMotor, lowFreqMotor, leftTrigger, rightTrigger }; - - CombinedVibration.ParallelCombination combo = CombinedVibration.startParallel(); - - for (int i = 0; i < vibratorIds.length; i++) { - // It's illegal to create a VibrationEffect with an amplitude of 0. - // Simply excluding that vibrator from our ParallelCombination will turn it off. - if (vibratorAmplitudes[i] != 0) { - combo.addVibrator(vibratorIds[i], VibrationEffect.createOneShot(60000, vibratorAmplitudes[i])); - } - } - - VibrationAttributes.Builder vibrationAttributes = new VibrationAttributes.Builder(); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - vibrationAttributes.setUsage(VibrationAttributes.USAGE_MEDIA); - } - - vm.vibrate(combo.combine(), vibrationAttributes.build()); - } - - private void rumbleSingleVibrator(Vibrator vibrator, short lowFreqMotor, short highFreqMotor) { - // Since we can only use a single amplitude value, compute the desired amplitude - // by taking 80% of the big motor and 33% of the small motor, then capping to 255. - // NB: This value is now 0-255 as required by VibrationEffect. - short lowFreqMotorMSB = (short)((lowFreqMotor >> 8) & 0xFF); - short highFreqMotorMSB = (short)((highFreqMotor >> 8) & 0xFF); - int simulatedAmplitude = Math.min(255, (int)((lowFreqMotorMSB * 0.80) + (highFreqMotorMSB * 0.33))); - - if (simulatedAmplitude == 0) { - // This case is easy - just cancel the current effect and get out. - // NB: We cannot simply check lowFreqMotor == highFreqMotor == 0 - // because our simulatedAmplitude could be 0 even though our inputs - // are not (ex: lowFreqMotor == 0 && highFreqMotor == 1). - vibrator.cancel(); - return; - } - - // Attempt to use amplitude-based control if we're on Oreo and the device - // supports amplitude-based vibration control. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - if (vibrator.hasAmplitudeControl()) { - VibrationEffect effect = VibrationEffect.createOneShot(60000, simulatedAmplitude); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - VibrationAttributes vibrationAttributes = new VibrationAttributes.Builder() - .setUsage(VibrationAttributes.USAGE_MEDIA) - .build(); - vibrator.vibrate(effect, vibrationAttributes); - } - else { - AudioAttributes audioAttributes = new AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_GAME) - .build(); - vibrator.vibrate(effect, audioAttributes); - } - return; - } - } - - // If we reach this point, we don't have amplitude controls available, so - // we must emulate it by PWMing the vibration. Ick. - long pwmPeriod = 20; - long onTime = (long)((simulatedAmplitude / 255.0) * pwmPeriod); - long offTime = pwmPeriod - onTime; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - VibrationAttributes vibrationAttributes = new VibrationAttributes.Builder() - .setUsage(VibrationAttributes.USAGE_MEDIA) - .build(); - vibrator.vibrate(VibrationEffect.createWaveform(new long[]{0, onTime, offTime}, 0), vibrationAttributes); - } - else { - AudioAttributes audioAttributes = new AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_GAME) - .build(); - vibrator.vibrate(new long[]{0, onTime, offTime}, 0, audioAttributes); - } - } - - public void handleRumble(short controllerNumber, short lowFreqMotor, short highFreqMotor) { - boolean foundMatchingDevice = false; - boolean vibrated = false; - - if (stopped) { - return; - } - - for (int i = 0; i < inputDeviceContexts.size(); i++) { - InputDeviceContext deviceContext = inputDeviceContexts.valueAt(i); - - if (deviceContext.controllerNumber == controllerNumber) { - foundMatchingDevice = true; - - deviceContext.lowFreqMotor = lowFreqMotor; - deviceContext.highFreqMotor = highFreqMotor; - - // Prefer the documented Android 12 rumble API which can handle dual vibrators on PS/Xbox controllers - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && deviceContext.vibratorManager != null) { - vibrated = true; - if (deviceContext.quadVibrators) { - rumbleQuadVibrators(deviceContext.vibratorManager, - deviceContext.lowFreqMotor, deviceContext.highFreqMotor, - deviceContext.leftTriggerMotor, deviceContext.rightTriggerMotor); - } - else { - rumbleDualVibrators(deviceContext.vibratorManager, - deviceContext.lowFreqMotor, deviceContext.highFreqMotor); - } - } - // On Shield devices, we can use their special API to rumble Shield controllers - else if (sceManager.rumble(deviceContext.inputDevice, deviceContext.lowFreqMotor, deviceContext.highFreqMotor)) { - vibrated = true; - } - // If all else fails, we have to try the old Vibrator API - else if (deviceContext.vibrator != null) { - vibrated = true; - rumbleSingleVibrator(deviceContext.vibrator, deviceContext.lowFreqMotor, deviceContext.highFreqMotor); - } - } - } - - for (int i = 0; i < usbDeviceContexts.size(); i++) { - UsbDeviceContext deviceContext = usbDeviceContexts.valueAt(i); - - if (deviceContext.controllerNumber == controllerNumber) { - foundMatchingDevice = vibrated = true; - deviceContext.device.rumble(lowFreqMotor, highFreqMotor); - } - } - - // We may decide to rumble the device for player 1 - if (controllerNumber == 0) { - // If we didn't find a matching device, it must be the on-screen - // controls that triggered the rumble. Vibrate the device if - // the user has requested that behavior. - if (!foundMatchingDevice && prefConfig.onscreenController && !prefConfig.onlyL3R3 && prefConfig.vibrateOsc) { - rumbleSingleVibrator(deviceVibrator, lowFreqMotor, highFreqMotor); - } - else if (foundMatchingDevice && !vibrated && prefConfig.vibrateFallbackToDevice) { - // We found a device to vibrate but it didn't have rumble support. The user - // has requested us to vibrate the device in this case. - - // We cast the unsigned short value to a signed int before multiplying by - // the preferred strength. The resulting value is capped at 65534 before - // we cast it back to a short so it doesn't go above 100%. - short lowFreqMotorAdjusted = (short)(Math.min((((lowFreqMotor & 0xffff) - * prefConfig.vibrateFallbackToDeviceStrength) / 100), Short.MAX_VALUE*2)); - short highFreqMotorAdjusted = (short)(Math.min((((highFreqMotor & 0xffff) - * prefConfig.vibrateFallbackToDeviceStrength) / 100), Short.MAX_VALUE*2)); - - rumbleSingleVibrator(deviceVibrator, lowFreqMotorAdjusted, highFreqMotorAdjusted); - } - } - } - - public void handleRumbleTriggers(short controllerNumber, short leftTrigger, short rightTrigger) { - if (stopped) { - return; - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - for (int i = 0; i < inputDeviceContexts.size(); i++) { - InputDeviceContext deviceContext = inputDeviceContexts.valueAt(i); - - if (deviceContext.controllerNumber == controllerNumber) { - deviceContext.leftTriggerMotor = leftTrigger; - deviceContext.rightTriggerMotor = rightTrigger; - - if (deviceContext.quadVibrators) { - rumbleQuadVibrators(deviceContext.vibratorManager, - deviceContext.lowFreqMotor, deviceContext.highFreqMotor, - deviceContext.leftTriggerMotor, deviceContext.rightTriggerMotor); - } - } - } - } - - for (int i = 0; i < usbDeviceContexts.size(); i++) { - UsbDeviceContext deviceContext = usbDeviceContexts.valueAt(i); - - if (deviceContext.controllerNumber == controllerNumber) { - deviceContext.device.rumbleTriggers(leftTrigger, rightTrigger); - } - } - } - - private SensorEventListener createSensorListener(final short controllerNumber, final byte motionType, final boolean needsDeviceOrientationCorrection) { - return new SensorEventListener() { - private float[] lastValues = new float[3]; - - @Override - public void onSensorChanged(SensorEvent sensorEvent) { - // Android will invoke our callback any time we get a new reading, - // even if the values are the same as last time. Don't report a - // duplicate set of values to save bandwidth. - if (sensorEvent.values[0] == lastValues[0] && - sensorEvent.values[1] == lastValues[1] && - sensorEvent.values[2] == lastValues[2]) { - return; - } - else { - lastValues[0] = sensorEvent.values[0]; - lastValues[1] = sensorEvent.values[1]; - lastValues[2] = sensorEvent.values[2]; - } - - int x = 0; - int y = 1; - int z = 2; - int xFactor = 1; - int yFactor = 1; - int zFactor = 1; - - if (needsDeviceOrientationCorrection) { - int deviceRotation = activityContext.getWindowManager().getDefaultDisplay().getRotation(); - switch (deviceRotation) { - case Surface.ROTATION_0: - case Surface.ROTATION_180: - x = 0; - y = 2; - z = 1; - break; - - case Surface.ROTATION_90: - case Surface.ROTATION_270: - x = 1; - y = 2; - z = 0; - break; - } - - switch (deviceRotation) { - case Surface.ROTATION_0: - zFactor = -1; - break; - case Surface.ROTATION_90: - xFactor = -1; - zFactor = -1; - break; - case Surface.ROTATION_180: - xFactor = -1; - break; - case Surface.ROTATION_270: - break; - } - } - - if (motionType == MoonBridge.LI_MOTION_TYPE_GYRO) { - // Convert from rad/s to deg/s - conn.sendControllerMotionEvent((byte) controllerNumber, - motionType, - sensorEvent.values[x] * xFactor * 57.2957795f, - sensorEvent.values[y] * yFactor * 57.2957795f, - sensorEvent.values[z] * zFactor * 57.2957795f); - } - else { - // Pass m/s^2 directly without conversion - conn.sendControllerMotionEvent((byte) controllerNumber, - motionType, - sensorEvent.values[x] * xFactor, - sensorEvent.values[y] * yFactor, - sensorEvent.values[z] * zFactor); - } - } - - @Override - public void onAccuracyChanged(Sensor sensor, int accuracy) {} - }; - } - - public void handleSetMotionEventState(final short controllerNumber, final byte motionType, short reportRateHz) { - if (stopped) { - return; - } - - // Report rate is restricted to <= 200 Hz without the HIGH_SAMPLING_RATE_SENSORS permission - reportRateHz = (short) Math.min(200, reportRateHz); - - for (int i = 0; i < inputDeviceContexts.size(); i++) { - InputDeviceContext deviceContext = inputDeviceContexts.valueAt(i); - - if (deviceContext.controllerNumber == controllerNumber) { - // Store the desired report rate even if we don't have sensors. In some cases, - // input devices can be reconfigured at runtime which results in a change where - // sensors disappear and reappear. By storing the desired report rate, we can - // reapply the desired motion sensor configuration after they reappear. - switch (motionType) { - case MoonBridge.LI_MOTION_TYPE_ACCEL: - deviceContext.accelReportRateHz = reportRateHz; - break; - case MoonBridge.LI_MOTION_TYPE_GYRO: - deviceContext.gyroReportRateHz = reportRateHz; - break; - } - - backgroundThreadHandler.removeCallbacks(deviceContext.enableSensorRunnable); - - SensorManager sm = deviceContext.sensorManager; - if (sm == null) { - continue; - } - - switch (motionType) { - case MoonBridge.LI_MOTION_TYPE_ACCEL: - if (deviceContext.accelListener != null) { - sm.unregisterListener(deviceContext.accelListener); - deviceContext.accelListener = null; - } - - // Enable the accelerometer if requested - Sensor accelSensor = sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER); - if (reportRateHz != 0 && accelSensor != null) { - deviceContext.accelListener = createSensorListener(controllerNumber, motionType, sm == deviceSensorManager); - sm.registerListener(deviceContext.accelListener, accelSensor, 1000000 / reportRateHz); - } - break; - case MoonBridge.LI_MOTION_TYPE_GYRO: - if (deviceContext.gyroListener != null) { - sm.unregisterListener(deviceContext.gyroListener); - deviceContext.gyroListener = null; - } - - // Enable the gyroscope if requested - Sensor gyroSensor = sm.getDefaultSensor(Sensor.TYPE_GYROSCOPE); - if (reportRateHz != 0 && gyroSensor != null) { - deviceContext.gyroListener = createSensorListener(controllerNumber, motionType, sm == deviceSensorManager); - sm.registerListener(deviceContext.gyroListener, gyroSensor, 1000000 / reportRateHz); - } - break; - } - break; - } - } - } - - public void handleSetControllerLED(short controllerNumber, byte r, byte g, byte b) { - if (stopped) { - return; - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - for (int i = 0; i < inputDeviceContexts.size(); i++) { - InputDeviceContext deviceContext = inputDeviceContexts.valueAt(i); - - // Ignore input devices without an RGB LED - if (deviceContext.controllerNumber == controllerNumber && deviceContext.hasRgbLed) { - // Create a new light session if one doesn't already exist - if (deviceContext.lightsSession == null) { - deviceContext.lightsSession = deviceContext.inputDevice.getLightsManager().openSession(); - } - - // Convert the RGB components into the integer value that LightState uses - int argbValue = 0xFF000000 | ((r << 16) & 0xFF0000) | ((g << 8) & 0xFF00) | (b & 0xFF); - LightState lightState = new LightState.Builder().setColor(argbValue).build(); - - // Set the RGB value for each RGB-controllable LED on the device - LightsRequest.Builder lightsRequestBuilder = new LightsRequest.Builder(); - for (Light light : deviceContext.inputDevice.getLightsManager().getLights()) { - if (light.hasRgbControl()) { - lightsRequestBuilder.addLight(light, lightState); - } - } - - // Apply the LED changes - deviceContext.lightsSession.requestLights(lightsRequestBuilder.build()); - } - } - } - } - - public boolean handleButtonUp(KeyEvent event) { - InputDeviceContext context = getContextForEvent(event); - if (context == null) { - return true; - } - - int keyCode = handleRemapping(context, event); - if (keyCode < 0) { - return (keyCode == REMAP_CONSUME); - } - - if (prefConfig.flipFaceButtons) { - keyCode = handleFlipFaceButtons(keyCode); - } - - // If the button hasn't been down long enough, sleep for a bit before sending the up event - // This allows "instant" button presses (like OUYA's virtual menu button) to work. This - // path should not be triggered during normal usage. - int buttonDownTime = (int)(event.getEventTime() - event.getDownTime()); - if (buttonDownTime < ControllerHandler.MINIMUM_BUTTON_DOWN_TIME_MS) - { - // Since our sleep time is so short (<= 25 ms), it shouldn't cause a problem doing this - // in the UI thread. - try { - Thread.sleep(ControllerHandler.MINIMUM_BUTTON_DOWN_TIME_MS - buttonDownTime); - } catch (InterruptedException e) { - e.printStackTrace(); - - // InterruptedException clears the thread's interrupt status. Since we can't - // handle that here, we will re-interrupt the thread to set the interrupt - // status back to true. - Thread.currentThread().interrupt(); - } - } - - switch (keyCode) { - case KeyEvent.KEYCODE_BUTTON_MODE: - context.inputMap &= ~ControllerPacket.SPECIAL_BUTTON_FLAG; - break; - case KeyEvent.KEYCODE_BUTTON_START: - case KeyEvent.KEYCODE_MENU: - // Sometimes we'll get a spurious key up event on controller disconnect. - // Make sure it's real by checking that the key is actually down before taking - // any action. - if ((context.inputMap & ControllerPacket.PLAY_FLAG) != 0 && - event.getEventTime() - context.startDownTime > ControllerHandler.START_DOWN_TIME_MOUSE_MODE_MS && - prefConfig.mouseEmulation) { - context.toggleMouseEmulation(); - } - context.inputMap &= ~ControllerPacket.PLAY_FLAG; - break; - case KeyEvent.KEYCODE_BACK: - case KeyEvent.KEYCODE_BUTTON_SELECT: - context.inputMap &= ~ControllerPacket.BACK_FLAG; - break; - case KeyEvent.KEYCODE_DPAD_LEFT: - if (context.hatXAxisUsed) { - // Suppress this duplicate event if we have a hat - return true; - } - context.inputMap &= ~ControllerPacket.LEFT_FLAG; - break; - case KeyEvent.KEYCODE_DPAD_RIGHT: - if (context.hatXAxisUsed) { - // Suppress this duplicate event if we have a hat - return true; - } - context.inputMap &= ~ControllerPacket.RIGHT_FLAG; - break; - case KeyEvent.KEYCODE_DPAD_UP: - if (context.hatYAxisUsed) { - // Suppress this duplicate event if we have a hat - return true; - } - context.inputMap &= ~ControllerPacket.UP_FLAG; - break; - case KeyEvent.KEYCODE_DPAD_DOWN: - if (context.hatYAxisUsed) { - // Suppress this duplicate event if we have a hat - return true; - } - context.inputMap &= ~ControllerPacket.DOWN_FLAG; - break; - case KeyEvent.KEYCODE_DPAD_UP_LEFT: - if (context.hatXAxisUsed && context.hatYAxisUsed) { - // Suppress this duplicate event if we have a hat - return true; - } - context.inputMap &= ~(ControllerPacket.UP_FLAG | ControllerPacket.LEFT_FLAG); - break; - case KeyEvent.KEYCODE_DPAD_UP_RIGHT: - if (context.hatXAxisUsed && context.hatYAxisUsed) { - // Suppress this duplicate event if we have a hat - return true; - } - context.inputMap &= ~(ControllerPacket.UP_FLAG | ControllerPacket.RIGHT_FLAG); - break; - case KeyEvent.KEYCODE_DPAD_DOWN_LEFT: - if (context.hatXAxisUsed && context.hatYAxisUsed) { - // Suppress this duplicate event if we have a hat - return true; - } - context.inputMap &= ~(ControllerPacket.DOWN_FLAG | ControllerPacket.LEFT_FLAG); - break; - case KeyEvent.KEYCODE_DPAD_DOWN_RIGHT: - if (context.hatXAxisUsed && context.hatYAxisUsed) { - // Suppress this duplicate event if we have a hat - return true; - } - context.inputMap &= ~(ControllerPacket.DOWN_FLAG | ControllerPacket.RIGHT_FLAG); - break; - case KeyEvent.KEYCODE_BUTTON_B: - context.inputMap &= ~ControllerPacket.B_FLAG; - break; - case KeyEvent.KEYCODE_DPAD_CENTER: - case KeyEvent.KEYCODE_BUTTON_A: - context.inputMap &= ~ControllerPacket.A_FLAG; - break; - case KeyEvent.KEYCODE_BUTTON_X: - context.inputMap &= ~ControllerPacket.X_FLAG; - break; - case KeyEvent.KEYCODE_BUTTON_Y: - context.inputMap &= ~ControllerPacket.Y_FLAG; - break; - case KeyEvent.KEYCODE_BUTTON_L1: - context.inputMap &= ~ControllerPacket.LB_FLAG; - context.lastLbUpTime = event.getEventTime(); - break; - case KeyEvent.KEYCODE_BUTTON_R1: - context.inputMap &= ~ControllerPacket.RB_FLAG; - context.lastRbUpTime = event.getEventTime(); - break; - case KeyEvent.KEYCODE_BUTTON_THUMBL: - context.inputMap &= ~ControllerPacket.LS_CLK_FLAG; - break; - case KeyEvent.KEYCODE_BUTTON_THUMBR: - context.inputMap &= ~ControllerPacket.RS_CLK_FLAG; - break; - case KeyEvent.KEYCODE_MEDIA_RECORD: // Xbox Series X Share button - context.inputMap &= ~ControllerPacket.MISC_FLAG; - break; - case KeyEvent.KEYCODE_BUTTON_1: // PS4/PS5 touchpad button (prior to 4.10) - context.inputMap &= ~ControllerPacket.TOUCHPAD_FLAG; - break; - case KeyEvent.KEYCODE_BUTTON_L2: - if (context.leftTriggerAxisUsed) { - // Suppress this digital event if an analog trigger is active - return true; - } - context.leftTrigger = 0; - break; - case KeyEvent.KEYCODE_BUTTON_R2: - if (context.rightTriggerAxisUsed) { - // Suppress this digital event if an analog trigger is active - return true; - } - context.rightTrigger = 0; - break; - case KeyEvent.KEYCODE_UNKNOWN: - // Paddles aren't mapped in any of the Android key layout files, - // so we need to handle the evdev key codes directly. - if (context.hasPaddles) { - switch (event.getScanCode()) { - case 0x2c4: // BTN_TRIGGER_HAPPY5 - context.inputMap &= ~ControllerPacket.PADDLE1_FLAG; - break; - case 0x2c5: // BTN_TRIGGER_HAPPY6 - context.inputMap &= ~ControllerPacket.PADDLE2_FLAG; - break; - case 0x2c6: // BTN_TRIGGER_HAPPY7 - context.inputMap &= ~ControllerPacket.PADDLE3_FLAG; - break; - case 0x2c7: // BTN_TRIGGER_HAPPY8 - context.inputMap &= ~ControllerPacket.PADDLE4_FLAG; - break; - default: - return false; - } - } - else { - return false; - } - break; - default: - return false; - } - - // Check if we're emulating the select button - if ((context.emulatingButtonFlags & ControllerHandler.EMULATING_SELECT) != 0) - { - // If either start or LB is up, select comes up too - if ((context.inputMap & ControllerPacket.PLAY_FLAG) == 0 || - (context.inputMap & ControllerPacket.LB_FLAG) == 0) - { - context.inputMap &= ~ControllerPacket.BACK_FLAG; - - context.emulatingButtonFlags &= ~ControllerHandler.EMULATING_SELECT; - } - } - - // Check if we're emulating the special button - if ((context.emulatingButtonFlags & ControllerHandler.EMULATING_SPECIAL) != 0) - { - // If either start or select and RB is up, the special button comes up too - if ((context.inputMap & ControllerPacket.PLAY_FLAG) == 0 || - ((context.inputMap & ControllerPacket.BACK_FLAG) == 0 && - (context.inputMap & ControllerPacket.RB_FLAG) == 0)) - { - context.inputMap &= ~ControllerPacket.SPECIAL_BUTTON_FLAG; - - context.emulatingButtonFlags &= ~ControllerHandler.EMULATING_SPECIAL; - } - } - - // Check if we're emulating the touchpad button - if ((context.emulatingButtonFlags & ControllerHandler.EMULATING_TOUCHPAD) != 0) - { - // If either select or LB is up, touchpad comes up too - if ((context.inputMap & ControllerPacket.BACK_FLAG) == 0 || - (context.inputMap & ControllerPacket.LB_FLAG) == 0) - { - context.inputMap &= ~ControllerPacket.TOUCHPAD_FLAG; - - context.emulatingButtonFlags &= ~ControllerHandler.EMULATING_TOUCHPAD; - } - } - - sendControllerInputPacket(context); - - if (context.pendingExit && context.inputMap == 0) { - // All buttons from the quit combo are lifted. Finish the activity now. - activityContext.finish(); - } - - return true; - } - - public boolean handleButtonDown(KeyEvent event) { - InputDeviceContext context = getContextForEvent(event); - if (context == null) { - return true; - } - - int keyCode = handleRemapping(context, event); - if (keyCode < 0) { - return (keyCode == REMAP_CONSUME); - } - - if (prefConfig.flipFaceButtons) { - keyCode = handleFlipFaceButtons(keyCode); - } - - switch (keyCode) { - case KeyEvent.KEYCODE_BUTTON_MODE: - context.hasMode = true; - context.inputMap |= ControllerPacket.SPECIAL_BUTTON_FLAG; - break; - case KeyEvent.KEYCODE_BUTTON_START: - case KeyEvent.KEYCODE_MENU: - if (event.getRepeatCount() == 0) { - context.startDownTime = event.getEventTime(); - } - context.inputMap |= ControllerPacket.PLAY_FLAG; - break; - case KeyEvent.KEYCODE_BACK: - case KeyEvent.KEYCODE_BUTTON_SELECT: - context.hasSelect = true; - context.inputMap |= ControllerPacket.BACK_FLAG; - break; - case KeyEvent.KEYCODE_DPAD_LEFT: - if (context.hatXAxisUsed) { - // Suppress this duplicate event if we have a hat - return true; - } - context.inputMap |= ControllerPacket.LEFT_FLAG; - break; - case KeyEvent.KEYCODE_DPAD_RIGHT: - if (context.hatXAxisUsed) { - // Suppress this duplicate event if we have a hat - return true; - } - context.inputMap |= ControllerPacket.RIGHT_FLAG; - break; - case KeyEvent.KEYCODE_DPAD_UP: - if (context.hatYAxisUsed) { - // Suppress this duplicate event if we have a hat - return true; - } - context.inputMap |= ControllerPacket.UP_FLAG; - break; - case KeyEvent.KEYCODE_DPAD_DOWN: - if (context.hatYAxisUsed) { - // Suppress this duplicate event if we have a hat - return true; - } - context.inputMap |= ControllerPacket.DOWN_FLAG; - break; - case KeyEvent.KEYCODE_DPAD_UP_LEFT: - if (context.hatXAxisUsed && context.hatYAxisUsed) { - // Suppress this duplicate event if we have a hat - return true; - } - context.inputMap |= ControllerPacket.UP_FLAG | ControllerPacket.LEFT_FLAG; - break; - case KeyEvent.KEYCODE_DPAD_UP_RIGHT: - if (context.hatXAxisUsed && context.hatYAxisUsed) { - // Suppress this duplicate event if we have a hat - return true; - } - context.inputMap |= ControllerPacket.UP_FLAG | ControllerPacket.RIGHT_FLAG; - break; - case KeyEvent.KEYCODE_DPAD_DOWN_LEFT: - if (context.hatXAxisUsed && context.hatYAxisUsed) { - // Suppress this duplicate event if we have a hat - return true; - } - context.inputMap |= ControllerPacket.DOWN_FLAG | ControllerPacket.LEFT_FLAG; - break; - case KeyEvent.KEYCODE_DPAD_DOWN_RIGHT: - if (context.hatXAxisUsed && context.hatYAxisUsed) { - // Suppress this duplicate event if we have a hat - return true; - } - context.inputMap |= ControllerPacket.DOWN_FLAG | ControllerPacket.RIGHT_FLAG; - break; - case KeyEvent.KEYCODE_BUTTON_B: - context.inputMap |= ControllerPacket.B_FLAG; - break; - case KeyEvent.KEYCODE_DPAD_CENTER: - case KeyEvent.KEYCODE_BUTTON_A: - context.inputMap |= ControllerPacket.A_FLAG; - break; - case KeyEvent.KEYCODE_BUTTON_X: - context.inputMap |= ControllerPacket.X_FLAG; - break; - case KeyEvent.KEYCODE_BUTTON_Y: - context.inputMap |= ControllerPacket.Y_FLAG; - break; - case KeyEvent.KEYCODE_BUTTON_L1: - context.inputMap |= ControllerPacket.LB_FLAG; - break; - case KeyEvent.KEYCODE_BUTTON_R1: - context.inputMap |= ControllerPacket.RB_FLAG; - break; - case KeyEvent.KEYCODE_BUTTON_THUMBL: - context.inputMap |= ControllerPacket.LS_CLK_FLAG; - break; - case KeyEvent.KEYCODE_BUTTON_THUMBR: - context.inputMap |= ControllerPacket.RS_CLK_FLAG; - break; - case KeyEvent.KEYCODE_MEDIA_RECORD: // Xbox Series X Share button - context.inputMap |= ControllerPacket.MISC_FLAG; - break; - case KeyEvent.KEYCODE_BUTTON_1: // PS4/PS5 touchpad button (prior to 4.10) - context.inputMap |= ControllerPacket.TOUCHPAD_FLAG; - break; - case KeyEvent.KEYCODE_BUTTON_L2: - if (context.leftTriggerAxisUsed) { - // Suppress this digital event if an analog trigger is active - return true; - } - context.leftTrigger = (byte)0xFF; - break; - case KeyEvent.KEYCODE_BUTTON_R2: - if (context.rightTriggerAxisUsed) { - // Suppress this digital event if an analog trigger is active - return true; - } - context.rightTrigger = (byte)0xFF; - break; - case KeyEvent.KEYCODE_UNKNOWN: - // Paddles aren't mapped in any of the Android key layout files, - // so we need to handle the evdev key codes directly. - if (context.hasPaddles) { - switch (event.getScanCode()) { - case 0x2c4: // BTN_TRIGGER_HAPPY5 - context.inputMap |= ControllerPacket.PADDLE1_FLAG; - break; - case 0x2c5: // BTN_TRIGGER_HAPPY6 - context.inputMap |= ControllerPacket.PADDLE2_FLAG; - break; - case 0x2c6: // BTN_TRIGGER_HAPPY7 - context.inputMap |= ControllerPacket.PADDLE3_FLAG; - break; - case 0x2c7: // BTN_TRIGGER_HAPPY8 - context.inputMap |= ControllerPacket.PADDLE4_FLAG; - break; - default: - return false; - } - } - else { - return false; - } - break; - default: - return false; - } - - // Start+Back+LB+RB is the quit combo - if (context.inputMap == (ControllerPacket.BACK_FLAG | ControllerPacket.PLAY_FLAG | - ControllerPacket.LB_FLAG | ControllerPacket.RB_FLAG)) { - // Wait for the combo to lift and then finish the activity - context.pendingExit = true; - } - - // Start+LB acts like select for controllers with one button - if (!context.hasSelect) { - if (context.inputMap == (ControllerPacket.PLAY_FLAG | ControllerPacket.LB_FLAG) || - (context.inputMap == ControllerPacket.PLAY_FLAG && - event.getEventTime() - context.lastLbUpTime <= MAXIMUM_BUMPER_UP_DELAY_MS)) - { - context.inputMap &= ~(ControllerPacket.PLAY_FLAG | ControllerPacket.LB_FLAG); - context.inputMap |= ControllerPacket.BACK_FLAG; - - context.emulatingButtonFlags |= ControllerHandler.EMULATING_SELECT; - } - } - else if (context.needsClickpadEmulation) { - // Select+LB acts like the clickpad when we're faking a PS4 controller for motion support - if (context.inputMap == (ControllerPacket.BACK_FLAG | ControllerPacket.LB_FLAG) || - (context.inputMap == ControllerPacket.BACK_FLAG && - event.getEventTime() - context.lastLbUpTime <= MAXIMUM_BUMPER_UP_DELAY_MS)) - { - context.inputMap &= ~(ControllerPacket.BACK_FLAG | ControllerPacket.LB_FLAG); - context.inputMap |= ControllerPacket.TOUCHPAD_FLAG; - - context.emulatingButtonFlags |= ControllerHandler.EMULATING_TOUCHPAD; - } - } - - // If there is a physical select button, we'll use Start+Select as the special button combo - // otherwise we'll use Start+RB. - if (!context.hasMode) { - if (context.hasSelect) { - if (context.inputMap == (ControllerPacket.PLAY_FLAG | ControllerPacket.BACK_FLAG)) { - context.inputMap &= ~(ControllerPacket.PLAY_FLAG | ControllerPacket.BACK_FLAG); - context.inputMap |= ControllerPacket.SPECIAL_BUTTON_FLAG; - - context.emulatingButtonFlags |= ControllerHandler.EMULATING_SPECIAL; - } - } - else { - if (context.inputMap == (ControllerPacket.PLAY_FLAG | ControllerPacket.RB_FLAG) || - (context.inputMap == ControllerPacket.PLAY_FLAG && - event.getEventTime() - context.lastRbUpTime <= MAXIMUM_BUMPER_UP_DELAY_MS)) - { - context.inputMap &= ~(ControllerPacket.PLAY_FLAG | ControllerPacket.RB_FLAG); - context.inputMap |= ControllerPacket.SPECIAL_BUTTON_FLAG; - - context.emulatingButtonFlags |= ControllerHandler.EMULATING_SPECIAL; - } - } - } - - // We don't need to send repeat key down events, but the platform - // sends us events that claim to be repeats but they're from different - // devices, so we just send them all and deal with some duplicates. - sendControllerInputPacket(context); - return true; - } - - public void reportOscState(int buttonFlags, - short leftStickX, short leftStickY, - short rightStickX, short rightStickY, - byte leftTrigger, byte rightTrigger) { - defaultContext.leftStickX = leftStickX; - defaultContext.leftStickY = leftStickY; - - defaultContext.rightStickX = rightStickX; - defaultContext.rightStickY = rightStickY; - - defaultContext.leftTrigger = leftTrigger; - defaultContext.rightTrigger = rightTrigger; - - defaultContext.inputMap = buttonFlags; - - sendControllerInputPacket(defaultContext); - } - - @Override - public void reportControllerState(int controllerId, int buttonFlags, - float leftStickX, float leftStickY, - float rightStickX, float rightStickY, - float leftTrigger, float rightTrigger) { - GenericControllerContext context = usbDeviceContexts.get(controllerId); - if (context == null) { - return; - } - - Vector2d leftStickVector = populateCachedVector(leftStickX, leftStickY); - - handleDeadZone(leftStickVector, context.leftStickDeadzoneRadius); - - context.leftStickX = (short) (leftStickVector.getX() * 0x7FFE); - context.leftStickY = (short) (-leftStickVector.getY() * 0x7FFE); - - Vector2d rightStickVector = populateCachedVector(rightStickX, rightStickY); - - handleDeadZone(rightStickVector, context.rightStickDeadzoneRadius); - - context.rightStickX = (short) (rightStickVector.getX() * 0x7FFE); - context.rightStickY = (short) (-rightStickVector.getY() * 0x7FFE); - - if (leftTrigger <= context.triggerDeadzone) { - leftTrigger = 0; - } - if (rightTrigger <= context.triggerDeadzone) { - rightTrigger = 0; - } - - context.leftTrigger = (byte)(leftTrigger * 0xFF); - context.rightTrigger = (byte)(rightTrigger * 0xFF); - - context.inputMap = buttonFlags; - - sendControllerInputPacket(context); - } - - @Override - public void deviceRemoved(AbstractController controller) { - UsbDeviceContext context = usbDeviceContexts.get(controller.getControllerId()); - if (context != null) { - LimeLog.info("Removed controller: "+controller.getControllerId()); - releaseControllerNumber(context); - context.destroy(); - usbDeviceContexts.remove(controller.getControllerId()); - } - } - - @Override - public void deviceAdded(AbstractController controller) { - if (stopped) { - return; - } - - UsbDeviceContext context = createUsbDeviceContextForDevice(controller); - usbDeviceContexts.put(controller.getControllerId(), context); - } - - class GenericControllerContext { - public int id; - public boolean external; - - public int vendorId; - public int productId; - - public float leftStickDeadzoneRadius; - public float rightStickDeadzoneRadius; - public float triggerDeadzone; - - public boolean assignedControllerNumber; - public boolean reservedControllerNumber; - public short controllerNumber; - - public int inputMap = 0; - public byte leftTrigger = 0x00; - public byte rightTrigger = 0x00; - public short rightStickX = 0x0000; - public short rightStickY = 0x0000; - public short leftStickX = 0x0000; - public short leftStickY = 0x0000; - - public boolean mouseEmulationActive; - public int mouseEmulationLastInputMap; - public final int mouseEmulationReportPeriod = 50; - - public final Runnable mouseEmulationRunnable = new Runnable() { - @Override - public void run() { - if (!mouseEmulationActive) { - return; - } - - // Send mouse events from analog sticks - if (prefConfig.analogStickForScrolling == PreferenceConfiguration.AnalogStickForScrolling.RIGHT) { - sendEmulatedMouseMove(leftStickX, leftStickY); - sendEmulatedMouseScroll(rightStickX, rightStickY); - } - else if (prefConfig.analogStickForScrolling == PreferenceConfiguration.AnalogStickForScrolling.LEFT) { - sendEmulatedMouseMove(rightStickX, rightStickY); - sendEmulatedMouseScroll(leftStickX, leftStickY); - } - else { - sendEmulatedMouseMove(leftStickX, leftStickY); - sendEmulatedMouseMove(rightStickX, rightStickY); - } - - // Requeue the callback - mainThreadHandler.postDelayed(this, mouseEmulationReportPeriod); - } - }; - - public void toggleMouseEmulation() { - mainThreadHandler.removeCallbacks(mouseEmulationRunnable); - mouseEmulationActive = !mouseEmulationActive; - Toast.makeText(activityContext, "Mouse emulation is: " + (mouseEmulationActive ? "ON" : "OFF"), Toast.LENGTH_SHORT).show(); - - if (mouseEmulationActive) { - mainThreadHandler.postDelayed(mouseEmulationRunnable, mouseEmulationReportPeriod); - } - } - - public void destroy() { - mouseEmulationActive = false; - mainThreadHandler.removeCallbacks(mouseEmulationRunnable); - } - - public void sendControllerArrival() {} - } - - class InputDeviceContext extends GenericControllerContext { - public String name; - public VibratorManager vibratorManager; - public Vibrator vibrator; - public boolean quadVibrators; - public short lowFreqMotor, highFreqMotor; - public short leftTriggerMotor, rightTriggerMotor; - - public SensorManager sensorManager; - public SensorEventListener gyroListener; - public short gyroReportRateHz; - public SensorEventListener accelListener; - public short accelReportRateHz; - - public InputDevice inputDevice; - - public boolean hasRgbLed; - public LightsManager.LightsSession lightsSession; - - // These are BatteryState values, not Moonlight values - public int lastReportedBatteryStatus; - public float lastReportedBatteryCapacity; - - public int leftStickXAxis = -1; - public int leftStickYAxis = -1; - - public int rightStickXAxis = -1; - public int rightStickYAxis = -1; - - public int leftTriggerAxis = -1; - public int rightTriggerAxis = -1; - public boolean triggersIdleNegative; - public boolean leftTriggerAxisUsed, rightTriggerAxisUsed; - - public int hatXAxis = -1; - public int hatYAxis = -1; - public boolean hatXAxisUsed, hatYAxisUsed; - - InputDevice.MotionRange touchpadXRange; - InputDevice.MotionRange touchpadYRange; - InputDevice.MotionRange touchpadPressureRange; - - public boolean isNonStandardDualShock4; - public boolean usesLinuxGamepadStandardFaceButtons; - public boolean isNonStandardXboxBtController; - public boolean isServal; - public boolean backIsStart; - public boolean modeIsSelect; - public boolean searchIsMode; - public boolean ignoreBack; - public boolean hasJoystickAxes; - public boolean pendingExit; - public boolean isDualShockStandaloneTouchpad; - - public int emulatingButtonFlags = 0; - public boolean hasSelect; - public boolean hasMode; - public boolean hasPaddles; - public boolean hasShare; - public boolean needsClickpadEmulation; - - // Used for OUYA bumper state tracking since they force all buttons - // up when the OUYA button goes down. We watch the last time we get - // a bumper up and compare that to our maximum delay when we receive - // a Start button press to see if we should activate one of our - // emulated button combos. - public long lastLbUpTime = 0; - public long lastRbUpTime = 0; - - public long startDownTime = 0; - - public final Runnable batteryStateUpdateRunnable = new Runnable() { - @Override - public void run() { - sendControllerBatteryPacket(InputDeviceContext.this); - - // Requeue the callback - backgroundThreadHandler.postDelayed(this, BATTERY_RECHECK_INTERVAL_MS); - } - }; - - public final Runnable enableSensorRunnable = new Runnable() { - @Override - public void run() { - // Turn back on any sensors that should be reporting but are currently unregistered - if (accelReportRateHz != 0 && accelListener == null) { - handleSetMotionEventState(controllerNumber, MoonBridge.LI_MOTION_TYPE_ACCEL, accelReportRateHz); - } - if (gyroReportRateHz != 0 && gyroListener == null) { - handleSetMotionEventState(controllerNumber, MoonBridge.LI_MOTION_TYPE_GYRO, gyroReportRateHz); - } - } - }; - - @Override - public void destroy() { - super.destroy(); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && vibratorManager != null) { - vibratorManager.cancel(); - } - else if (vibrator != null) { - vibrator.cancel(); - } - - backgroundThreadHandler.removeCallbacks(enableSensorRunnable); - - if (gyroListener != null) { - sensorManager.unregisterListener(gyroListener); - } - if (accelListener != null) { - sensorManager.unregisterListener(accelListener); - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - if (lightsSession != null) { - lightsSession.close(); - } - } - - backgroundThreadHandler.removeCallbacks(batteryStateUpdateRunnable); - } - - @Override - public void sendControllerArrival() { - byte type; - switch (inputDevice.getVendorId()) { - case 0x045e: // Microsoft - type = MoonBridge.LI_CTYPE_XBOX; - break; - case 0x054c: // Sony - type = MoonBridge.LI_CTYPE_PS; - break; - case 0x057e: // Nintendo - type = MoonBridge.LI_CTYPE_NINTENDO; - break; - default: - // Consult SDL's controller type list to see if it knows - type = MoonBridge.guessControllerType(inputDevice.getVendorId(), inputDevice.getProductId()); - break; - } - - int supportedButtonFlags = 0; - for (Map.Entry entry : ANDROID_TO_LI_BUTTON_MAP.entrySet()) { - if (inputDevice.hasKeys(entry.getKey())[0]) { - supportedButtonFlags |= entry.getValue(); - } - } - - // Add non-standard button flags that may not be mapped in the Android kl file - if (hasPaddles) { - supportedButtonFlags |= - ControllerPacket.PADDLE1_FLAG | - ControllerPacket.PADDLE2_FLAG | - ControllerPacket.PADDLE3_FLAG | - ControllerPacket.PADDLE4_FLAG; - } - if (hasShare) { - supportedButtonFlags |= ControllerPacket.MISC_FLAG; - } - - if (getMotionRangeForJoystickAxis(inputDevice, MotionEvent.AXIS_HAT_X) != null) { - supportedButtonFlags |= ControllerPacket.LEFT_FLAG | ControllerPacket.RIGHT_FLAG; - } - if (getMotionRangeForJoystickAxis(inputDevice, MotionEvent.AXIS_HAT_Y) != null) { - supportedButtonFlags |= ControllerPacket.UP_FLAG | ControllerPacket.DOWN_FLAG; - } - - short capabilities = 0; - - // Most of the advanced InputDevice capabilities came in Android S - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - if (quadVibrators) { - capabilities |= MoonBridge.LI_CCAP_RUMBLE | MoonBridge.LI_CCAP_TRIGGER_RUMBLE; - } - else if (vibratorManager != null || vibrator != null) { - capabilities |= MoonBridge.LI_CCAP_RUMBLE; - } - - // Calling InputDevice.getBatteryState() to see if a battery is present - // performs a Binder transaction that can cause ANRs on some devices. - // To avoid this, we will just claim we can report battery state for all - // external gamepad devices on Android S. If it turns out that no battery - // is actually present, we'll just report unknown battery state to the host. - if (external) { - capabilities |= MoonBridge.LI_CCAP_BATTERY_STATE; - } - - // Light.hasRgbControl() was totally broken prior to Android 14. - // It always returned true because LIGHT_CAPABILITY_RGB was defined as 0, - // so we will just guess RGB is supported if it's a PlayStation controller. - if (hasRgbLed && (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE || type == MoonBridge.LI_CTYPE_PS)) { - capabilities |= MoonBridge.LI_CCAP_RGB_LED; - } - } - - // Report analog triggers if we have at least one trigger axis - if (leftTriggerAxis != -1 || rightTriggerAxis != -1) { - capabilities |= MoonBridge.LI_CCAP_ANALOG_TRIGGERS; - } - - // Report sensors if the input device has them or we're using built-in sensors for a built-in controller - if (sensorManager != null && sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null) { - capabilities |= MoonBridge.LI_CCAP_ACCEL; - } - if (sensorManager != null && sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE) != null) { - capabilities |= MoonBridge.LI_CCAP_GYRO; - } - - byte reportedType; - if (type != MoonBridge.LI_CTYPE_PS && sensorManager != null) { - // Override the detected controller type if we're emulating motion sensors on an Xbox controller - Toast.makeText(activityContext, activityContext.getResources().getText(R.string.toast_controller_type_changed), Toast.LENGTH_LONG).show(); - reportedType = MoonBridge.LI_CTYPE_UNKNOWN; - - // Remember that we should enable the clickpad emulation combo (Select+LB) for this device - needsClickpadEmulation = true; - } - else { - // Report the true type to the host PC if we're not emulating motion sensors - reportedType = type; - } - - // We can perform basic rumble with any vibrator - if (vibrator != null) { - capabilities |= MoonBridge.LI_CCAP_RUMBLE; - } - - // Shield controllers use special APIs for rumble and battery state - if (sceManager.isRecognizedDevice(inputDevice)) { - capabilities |= MoonBridge.LI_CCAP_RUMBLE | MoonBridge.LI_CCAP_BATTERY_STATE; - } - - if ((inputDevice.getSources() & InputDevice.SOURCE_TOUCHPAD) == InputDevice.SOURCE_TOUCHPAD) { - capabilities |= MoonBridge.LI_CCAP_TOUCHPAD; - - // Use the platform API or internal heuristics to determine if this has a clickpad - if (hasButtonUnderTouchpad(inputDevice, type)) { - supportedButtonFlags |= ControllerPacket.TOUCHPAD_FLAG; - } - } - - conn.sendControllerArrivalEvent((byte)controllerNumber, getActiveControllerMask(), - reportedType, supportedButtonFlags, capabilities); - - // After reporting arrival to the host, send initial battery state and begin monitoring - backgroundThreadHandler.post(batteryStateUpdateRunnable); - } - - public void migrateContext(InputDeviceContext oldContext) { - // Take ownership of the sensor and light sessions - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - this.lightsSession = oldContext.lightsSession; - oldContext.lightsSession = null; - } - this.gyroReportRateHz = oldContext.gyroReportRateHz; - this.accelReportRateHz = oldContext.accelReportRateHz; - - // Don't release the controller number, because we will carry it over if it is present. - // We also want to make sure the change is invisible to the host PC to avoid an add/remove - // cycle for the gamepad which may break some games. - oldContext.destroy(); - - // Copy over existing controller number state - this.assignedControllerNumber = oldContext.assignedControllerNumber; - this.reservedControllerNumber = oldContext.reservedControllerNumber; - this.controllerNumber = oldContext.controllerNumber; - - // We may have set this device to use the built-in sensor manager. If so, do that again. - if (oldContext.sensorManager == deviceSensorManager) { - this.sensorManager = deviceSensorManager; - } - - // Copy state initialized in reportControllerArrival() - this.needsClickpadEmulation = oldContext.needsClickpadEmulation; - - // Re-enable sensors on the new context - enableSensors(); - - // Refresh battery state and start the battery state polling again - backgroundThreadHandler.post(batteryStateUpdateRunnable); - } - - public void disableSensors() { - // Stop any pending enablement - backgroundThreadHandler.removeCallbacks(enableSensorRunnable); - - // Unregister all sensor listeners - if (gyroListener != null) { - sensorManager.unregisterListener(gyroListener); - gyroListener = null; - - // Send a gyro event to ensure the virtual controller is stationary - conn.sendControllerMotionEvent((byte) controllerNumber, MoonBridge.LI_MOTION_TYPE_GYRO, 0.f, 0.f, 0.f); - } - if (accelListener != null) { - sensorManager.unregisterListener(accelListener); - accelListener = null; - - // We leave the acceleration as-is to preserve the attitude of the controller - } - } - - public void enableSensors() { - // We allow 1 second for the input device to settle before re-enabling sensors. - // Pointer capture can cause the input device to change, which can cause - // InputDeviceSensorManager to crash due to missing null checks on the InputDevice. - backgroundThreadHandler.postDelayed(enableSensorRunnable, 1000); - } - } - - class UsbDeviceContext extends GenericControllerContext { - public AbstractController device; - - @Override - public void destroy() { - super.destroy(); - - // Nothing for now - } - - @Override - public void sendControllerArrival() { - conn.sendControllerArrivalEvent((byte)controllerNumber, getActiveControllerMask(), - device.getType(), device.getSupportedButtonFlags(), device.getCapabilities()); - } - } -} +package com.limelight.binding.input; + +import android.annotation.TargetApi; +import android.app.Activity; +import android.content.Context; +import android.hardware.BatteryState; +import android.hardware.Sensor; +import android.hardware.SensorEvent; +import android.hardware.SensorEventListener; +import android.hardware.SensorManager; +import android.hardware.input.InputManager; +import android.hardware.lights.Light; +import android.hardware.lights.LightState; +import android.hardware.lights.LightsManager; +import android.hardware.lights.LightsRequest; +import android.hardware.usb.UsbDevice; +import android.hardware.usb.UsbManager; +import android.media.AudioAttributes; +import android.os.Build; +import android.os.CombinedVibration; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; +import android.os.VibrationAttributes; +import android.os.VibrationEffect; +import android.os.Vibrator; +import android.os.VibratorManager; +import android.util.SparseArray; +import android.view.InputDevice; +import android.view.InputEvent; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.Surface; +import android.widget.Toast; + +import com.limelight.GameMenu; +import com.limelight.LimeLog; +import com.limelight.R; +import com.limelight.binding.input.driver.AbstractController; +import com.limelight.binding.input.driver.UsbDriverListener; +import com.limelight.binding.input.driver.UsbDriverService; +import com.limelight.nvstream.NvConnection; +import com.limelight.nvstream.input.ControllerPacket; +import com.limelight.nvstream.input.MouseButtonPacket; +import com.limelight.nvstream.jni.MoonBridge; +import com.limelight.preferences.PreferenceConfiguration; +import com.limelight.ui.GameGestures; +import com.limelight.utils.Vector2d; + +import org.cgutman.shieldcontrollerextensions.SceChargingState; +import org.cgutman.shieldcontrollerextensions.SceConnectionType; +import org.cgutman.shieldcontrollerextensions.SceManager; + +import java.lang.reflect.InvocationTargetException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public class ControllerHandler implements InputManager.InputDeviceListener, UsbDriverListener { + + private static final int MAXIMUM_BUMPER_UP_DELAY_MS = 100; + + private static final int START_DOWN_TIME_MOUSE_MODE_MS = 750; + + private static final int MINIMUM_BUTTON_DOWN_TIME_MS = 25; + + private static final int EMULATING_SPECIAL = 0x1; + private static final int EMULATING_SELECT = 0x2; + private static final int EMULATING_TOUCHPAD = 0x4; + + private static final short MAX_GAMEPADS = 16; // Limited by bits in activeGamepadMask + + private static final int BATTERY_RECHECK_INTERVAL_MS = 120 * 1000; + + private static final Map ANDROID_TO_LI_BUTTON_MAP = Map.ofEntries( + Map.entry(KeyEvent.KEYCODE_BUTTON_A, ControllerPacket.A_FLAG), + Map.entry(KeyEvent.KEYCODE_BUTTON_B, ControllerPacket.B_FLAG), + Map.entry(KeyEvent.KEYCODE_BUTTON_X, ControllerPacket.X_FLAG), + Map.entry(KeyEvent.KEYCODE_BUTTON_Y, ControllerPacket.Y_FLAG), + Map.entry(KeyEvent.KEYCODE_DPAD_UP, ControllerPacket.UP_FLAG), + Map.entry(KeyEvent.KEYCODE_DPAD_DOWN, ControllerPacket.DOWN_FLAG), + Map.entry(KeyEvent.KEYCODE_DPAD_LEFT, ControllerPacket.LEFT_FLAG), + Map.entry(KeyEvent.KEYCODE_DPAD_RIGHT, ControllerPacket.RIGHT_FLAG), + Map.entry(KeyEvent.KEYCODE_DPAD_UP_LEFT, ControllerPacket.UP_FLAG | ControllerPacket.LEFT_FLAG), + Map.entry(KeyEvent.KEYCODE_DPAD_UP_RIGHT, ControllerPacket.UP_FLAG | ControllerPacket.RIGHT_FLAG), + Map.entry(KeyEvent.KEYCODE_DPAD_DOWN_LEFT, ControllerPacket.DOWN_FLAG | ControllerPacket.LEFT_FLAG), + Map.entry(KeyEvent.KEYCODE_DPAD_DOWN_RIGHT, ControllerPacket.DOWN_FLAG | ControllerPacket.RIGHT_FLAG), + Map.entry(KeyEvent.KEYCODE_BUTTON_L1, ControllerPacket.LB_FLAG), + Map.entry(KeyEvent.KEYCODE_BUTTON_R1, ControllerPacket.RB_FLAG), + Map.entry(KeyEvent.KEYCODE_BUTTON_THUMBL, ControllerPacket.LS_CLK_FLAG), + Map.entry(KeyEvent.KEYCODE_BUTTON_THUMBR, ControllerPacket.RS_CLK_FLAG), + Map.entry(KeyEvent.KEYCODE_BUTTON_START, ControllerPacket.PLAY_FLAG), + Map.entry(KeyEvent.KEYCODE_MENU, ControllerPacket.PLAY_FLAG), + Map.entry(KeyEvent.KEYCODE_BUTTON_SELECT, ControllerPacket.BACK_FLAG), + Map.entry(KeyEvent.KEYCODE_BACK, ControllerPacket.BACK_FLAG), + Map.entry(KeyEvent.KEYCODE_BUTTON_MODE, ControllerPacket.SPECIAL_BUTTON_FLAG), + + // This is the Xbox Series X Share button + Map.entry(KeyEvent.KEYCODE_MEDIA_RECORD, ControllerPacket.MISC_FLAG), + + // This is a weird one, but it's what Android does prior to 4.10 kernels + // where DualShock/DualSense touchpads weren't mapped as separate devices. + // https://android.googlesource.com/platform/frameworks/base/+/master/data/keyboards/Vendor_054c_Product_0ce6_fallback.kl + // https://android.googlesource.com/platform/frameworks/base/+/master/data/keyboards/Vendor_054c_Product_09cc.kl + Map.entry(KeyEvent.KEYCODE_BUTTON_1, ControllerPacket.TOUCHPAD_FLAG) + + // FIXME: Paddles? + ); + + private final Vector2d inputVector = new Vector2d(); + + private final SparseArray inputDeviceContexts = new SparseArray<>(); + private final SparseArray usbDeviceContexts = new SparseArray<>(); + + private final NvConnection conn; + private final Activity activityContext; + private final double stickDeadzone; + private final InputDeviceContext defaultContext = new InputDeviceContext(); + private final GameGestures gestures; + private final InputManager inputManager; + private final Vibrator deviceVibrator; + private final VibratorManager deviceVibratorManager; + private final SensorManager deviceSensorManager; + private final SceManager sceManager; + private final Handler mainThreadHandler; + private final HandlerThread backgroundHandlerThread; + private final Handler backgroundThreadHandler; + private boolean hasGameController; + private boolean stopped = false; + + private final PreferenceConfiguration prefConfig; + private short currentControllers, initialControllers; + + public ControllerHandler(Activity activityContext, NvConnection conn, GameGestures gestures, PreferenceConfiguration prefConfig) { + this.activityContext = activityContext; + this.conn = conn; + this.gestures = gestures; + this.prefConfig = prefConfig; + this.deviceVibrator = (Vibrator) activityContext.getSystemService(Context.VIBRATOR_SERVICE); + this.deviceSensorManager = (SensorManager) activityContext.getSystemService(Context.SENSOR_SERVICE); + this.inputManager = (InputManager) activityContext.getSystemService(Context.INPUT_SERVICE); + this.mainThreadHandler = new Handler(Looper.getMainLooper()); + + // Create a HandlerThread to process battery state updates. These can be slow enough + // that they lead to ANRs if we do them on the main thread. + this.backgroundHandlerThread = new HandlerThread("ControllerHandler"); + this.backgroundHandlerThread.start(); + this.backgroundThreadHandler = new Handler(backgroundHandlerThread.getLooper()); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + this.deviceVibratorManager = (VibratorManager) activityContext.getSystemService(Context.VIBRATOR_MANAGER_SERVICE); + } + else { + this.deviceVibratorManager = null; + } + + this.sceManager = new SceManager(activityContext); + this.sceManager.start(); + + int deadzonePercentage = prefConfig.deadzonePercentage; + + int[] ids = InputDevice.getDeviceIds(); + for (int id : ids) { + InputDevice dev = InputDevice.getDevice(id); + if (dev == null) { + // This device was removed during enumeration + continue; + } + if ((dev.getSources() & InputDevice.SOURCE_JOYSTICK) != 0 || + (dev.getSources() & InputDevice.SOURCE_GAMEPAD) != 0) { + // This looks like a gamepad, but we'll check X and Y to be sure + if (getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_X) != null && + getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_Y) != null) { + // This is a gamepad + hasGameController = true; + } + } + } + + // 1% is the lowest possible deadzone we support + if (deadzonePercentage <= 0) { + deadzonePercentage = 1; + } + + this.stickDeadzone = (double)deadzonePercentage / 100.0; + + // Initialize the default context for events with no device + defaultContext.leftStickXAxis = MotionEvent.AXIS_X; + defaultContext.leftStickYAxis = MotionEvent.AXIS_Y; + defaultContext.leftStickDeadzoneRadius = (float) stickDeadzone; + defaultContext.rightStickXAxis = MotionEvent.AXIS_Z; + defaultContext.rightStickYAxis = MotionEvent.AXIS_RZ; + defaultContext.rightStickDeadzoneRadius = (float) stickDeadzone; + defaultContext.leftTriggerAxis = MotionEvent.AXIS_BRAKE; + defaultContext.rightTriggerAxis = MotionEvent.AXIS_GAS; + defaultContext.hatXAxis = MotionEvent.AXIS_HAT_X; + defaultContext.hatYAxis = MotionEvent.AXIS_HAT_Y; + defaultContext.controllerNumber = (short) 0; + defaultContext.assignedControllerNumber = true; + defaultContext.external = false; + + // Some devices (GPD XD) have a back button which sends input events + // with device ID == 0. This hits the default context which would normally + // consume these. Instead, let's ignore them since that's probably the + // most likely case. + defaultContext.ignoreBack = true; + + // Get the initially attached set of gamepads. As each gamepad receives + // its initial InputEvent, we will move these from this set onto the + // currentControllers set which will allow them to properly unplug + // if they are removed. + initialControllers = getAttachedControllerMask(activityContext); + + // Register ourselves for input device notifications + inputManager.registerInputDeviceListener(this, null); + } + + private static InputDevice.MotionRange getMotionRangeForJoystickAxis(InputDevice dev, int axis) { + InputDevice.MotionRange range; + + // First get the axis for SOURCE_JOYSTICK + range = dev.getMotionRange(axis, InputDevice.SOURCE_JOYSTICK); + if (range == null) { + // Now try the axis for SOURCE_GAMEPAD + range = dev.getMotionRange(axis, InputDevice.SOURCE_GAMEPAD); + } + + return range; + } + + @Override + public void onInputDeviceAdded(int deviceId) { + // Nothing happening here yet + } + + @Override + public void onInputDeviceRemoved(int deviceId) { + InputDeviceContext context = inputDeviceContexts.get(deviceId); + if (context != null) { + LimeLog.info("Removed controller: "+context.name+" ("+deviceId+")"); + releaseControllerNumber(context); + context.destroy(); + inputDeviceContexts.remove(deviceId); + } + } + + // This can happen when gaining/losing input focus with some devices. + // Input devices that have a trackpad may gain/lose AXIS_RELATIVE_X/Y. + @Override + public void onInputDeviceChanged(int deviceId) { + InputDevice device = InputDevice.getDevice(deviceId); + if (device == null) { + return; + } + + // If we don't have a context for this device, we don't need to update anything + InputDeviceContext existingContext = inputDeviceContexts.get(deviceId); + if (existingContext == null) { + return; + } + + LimeLog.info("Device changed: "+existingContext.name+" ("+deviceId+")"); + + // Migrate the existing context into this new one by moving any stateful elements + InputDeviceContext newContext = createInputDeviceContextForDevice(device); + newContext.migrateContext(existingContext); + inputDeviceContexts.put(deviceId, newContext); + } + + public void stop() { + if (stopped) { + return; + } + + // Stop new device contexts from being created or used + stopped = true; + + // Unregister our input device callbacks + inputManager.unregisterInputDeviceListener(this); + + for (int i = 0; i < inputDeviceContexts.size(); i++) { + InputDeviceContext deviceContext = inputDeviceContexts.valueAt(i); + deviceContext.destroy(); + } + + for (int i = 0; i < usbDeviceContexts.size(); i++) { + UsbDeviceContext deviceContext = usbDeviceContexts.valueAt(i); + deviceContext.destroy(); + } + + deviceVibrator.cancel(); + } + + public void destroy() { + if (!stopped) { + stop(); + } + + sceManager.stop(); + backgroundHandlerThread.quit(); + } + + public void disableSensors() { + for (int i = 0; i < inputDeviceContexts.size(); i++) { + InputDeviceContext deviceContext = inputDeviceContexts.valueAt(i); + deviceContext.disableSensors(); + } + } + + public void enableSensors() { + if (stopped) { + return; + } + + for (int i = 0; i < inputDeviceContexts.size(); i++) { + InputDeviceContext deviceContext = inputDeviceContexts.valueAt(i); + deviceContext.enableSensors(); + } + } + + private static boolean hasJoystickAxes(InputDevice device) { + return (device.getSources() & InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK && + getMotionRangeForJoystickAxis(device, MotionEvent.AXIS_X) != null && + getMotionRangeForJoystickAxis(device, MotionEvent.AXIS_Y) != null; + } + + private static boolean hasGamepadButtons(InputDevice device) { + return (device.getSources() & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD; + } + + public static boolean isGameControllerDevice(InputDevice device) { + if (device == null) { + return true; + } + + if (hasJoystickAxes(device) || hasGamepadButtons(device)) { + // Has real joystick axes or gamepad buttons + return true; + } + + // HACK for https://issuetracker.google.com/issues/163120692 + if (Build.VERSION.SDK_INT == Build.VERSION_CODES.R) { + if (device.getId() == -1) { + // This "virtual" device could be input from any of the attached devices. + // Look to see if any gamepads are connected. + int[] ids = InputDevice.getDeviceIds(); + for (int id : ids) { + InputDevice dev = InputDevice.getDevice(id); + if (dev == null) { + // This device was removed during enumeration + continue; + } + + // If there are any gamepad devices connected, we'll + // report that this virtual device is a gamepad. + if (hasJoystickAxes(dev) || hasGamepadButtons(dev)) { + return true; + } + } + } + } + + // Otherwise, we'll try anything that claims to be a non-alphabetic keyboard + return device.getKeyboardType() != InputDevice.KEYBOARD_TYPE_ALPHABETIC; + } + + public static short getAttachedControllerMask(Context context) { + int count = 0; + short mask = 0; + + // Count all input devices that are gamepads + InputManager im = (InputManager) context.getSystemService(Context.INPUT_SERVICE); + for (int id : im.getInputDeviceIds()) { + InputDevice dev = im.getInputDevice(id); + if (dev == null) { + continue; + } + + if (hasJoystickAxes(dev)) { + LimeLog.info("Counting InputDevice: "+dev.getName()); + mask |= 1 << count++; + } + } + + // Count all USB devices that match our drivers + if (PreferenceConfiguration.readPreferences(context).usbDriver) { + UsbManager usbManager = (UsbManager) context.getSystemService(Context.USB_SERVICE); + if (usbManager != null) { + for (UsbDevice dev : usbManager.getDeviceList().values()) { + // We explicitly check not to claim devices that appear as InputDevices + // otherwise we will double count them. + if (UsbDriverService.shouldClaimDevice(dev, false) && + !UsbDriverService.isRecognizedInputDevice(dev)) { + LimeLog.info("Counting UsbDevice: "+dev.getDeviceName()); + mask |= 1 << count++; + } + } + } + } + + if (PreferenceConfiguration.readPreferences(context).onscreenController) { + LimeLog.info("Counting OSC gamepad"); + mask |= 1; + } + + LimeLog.info("Enumerated "+count+" gamepads"); + return mask; + } + + private void releaseControllerNumber(GenericControllerContext context) { + // If we reserved a controller number, remove that reservation + if (context.reservedControllerNumber) { + LimeLog.info("Controller number "+context.controllerNumber+" is now available"); + currentControllers &= ~(1 << context.controllerNumber); + } + + // If this device sent data as a gamepad, zero the values before removing. + // We must do this after clearing the currentControllers entry so this + // causes the device to be removed on the server PC. + if (context.assignedControllerNumber) { + conn.sendControllerInput(context.controllerNumber, getActiveControllerMask(), + (short) 0, + (byte) 0, (byte) 0, + (short) 0, (short) 0, + (short) 0, (short) 0); + } + } + + private boolean isAssociatedJoystick(InputDevice originalDevice, InputDevice possibleAssociatedJoystick) { + if (possibleAssociatedJoystick == null) { + return false; + } + + // This can't be an associated joystick if it's not a joystick + if ((possibleAssociatedJoystick.getSources() & InputDevice.SOURCE_JOYSTICK) != InputDevice.SOURCE_JOYSTICK) { + return false; + } + + // Make sure the device names *don't* match in order to prevent us from accidentally matching + // on another of the exact same device. + if (possibleAssociatedJoystick.getName().equals(originalDevice.getName())) { + return false; + } + + // Make sure the descriptor matches. This can match in cases where two of the exact same + // input device are connected, so we perform the name check to exclude that case. + if (!possibleAssociatedJoystick.getDescriptor().equals(originalDevice.getDescriptor())) { + return false; + } + + return true; + } + + // Called before sending input but after we've determined that this + // is definitely a controller (not a keyboard, mouse, or something else) + private void assignControllerNumberIfNeeded(GenericControllerContext context) { + if (context.assignedControllerNumber) { + return; + } + + if (context instanceof InputDeviceContext) { + InputDeviceContext devContext = (InputDeviceContext) context; + + LimeLog.info(devContext.name+" ("+context.id+") needs a controller number assigned"); + if (!devContext.external) { + LimeLog.info("Built-in buttons hardcoded as controller 0"); + context.controllerNumber = 0; + } + else if (prefConfig.multiController && devContext.hasJoystickAxes) { + context.controllerNumber = 0; + + LimeLog.info("Reserving the next available controller number"); + for (short i = 0; i < MAX_GAMEPADS; i++) { + if ((currentControllers & (1 << i)) == 0) { + // Found an unused controller value + currentControllers |= (1 << i); + + // Take this value out of the initial gamepad set + initialControllers &= ~(1 << i); + + context.controllerNumber = i; + context.reservedControllerNumber = true; + break; + } + } + } + else if (!devContext.hasJoystickAxes) { + // If this device doesn't have joystick axes, it may be an input device associated + // with another joystick (like a PS4 touchpad). We'll propagate that joystick's + // controller number to this associated device. + + context.controllerNumber = 0; + + // For the DS4 case, the associated joystick is the next device after the touchpad. + // We'll try the opposite case too, just to be a little future-proof. + InputDevice associatedDevice = InputDevice.getDevice(devContext.id + 1); + if (!isAssociatedJoystick(devContext.inputDevice, associatedDevice)) { + associatedDevice = InputDevice.getDevice(devContext.id - 1); + if (!isAssociatedJoystick(devContext.inputDevice, associatedDevice)) { + LimeLog.info("No associated joystick device found"); + associatedDevice = null; + } + } + + if (associatedDevice != null) { + InputDeviceContext associatedDeviceContext = inputDeviceContexts.get(associatedDevice.getId()); + + // Create a new context for the associated device if one doesn't exist + if (associatedDeviceContext == null) { + associatedDeviceContext = createInputDeviceContextForDevice(associatedDevice); + inputDeviceContexts.put(associatedDevice.getId(), associatedDeviceContext); + } + + // Assign a controller number for the associated device if one isn't assigned + if (!associatedDeviceContext.assignedControllerNumber) { + assignControllerNumberIfNeeded(associatedDeviceContext); + } + + // Propagate the associated controller number + context.controllerNumber = associatedDeviceContext.controllerNumber; + + LimeLog.info("Propagated controller number from "+associatedDeviceContext.name); + } + } + else { + LimeLog.info("Not reserving a controller number"); + context.controllerNumber = 0; + } + + // If the gamepad doesn't have motion sensors, use the on-device sensors as a fallback for player 1 + if (prefConfig.gamepadMotionSensorsFallbackToDevice && context.controllerNumber == 0 && devContext.sensorManager == null) { + devContext.sensorManager = deviceSensorManager; + } + } + else { + if (prefConfig.multiController) { + context.controllerNumber = 0; + + LimeLog.info("Reserving the next available controller number"); + for (short i = 0; i < MAX_GAMEPADS; i++) { + if ((currentControllers & (1 << i)) == 0) { + // Found an unused controller value + currentControllers |= (1 << i); + + // Take this value out of the initial gamepad set + initialControllers &= ~(1 << i); + + context.controllerNumber = i; + context.reservedControllerNumber = true; + break; + } + } + } + else { + LimeLog.info("Not reserving a controller number"); + context.controllerNumber = 0; + } + } + + LimeLog.info("Assigned as controller "+context.controllerNumber); + context.assignedControllerNumber = true; + + // Report attributes of this new controller to the host + context.sendControllerArrival(); + } + + private UsbDeviceContext createUsbDeviceContextForDevice(AbstractController device) { + UsbDeviceContext context = new UsbDeviceContext(); + + context.id = device.getControllerId(); + context.device = device; + context.external = true; + + context.vendorId = device.getVendorId(); + context.productId = device.getProductId(); + + context.leftStickDeadzoneRadius = (float) stickDeadzone; + context.rightStickDeadzoneRadius = (float) stickDeadzone; + context.triggerDeadzone = 0.13f; + + return context; + } + + private static boolean hasButtonUnderTouchpad(InputDevice dev, byte type) { + // It has to have a touchpad to have a button under it + if ((dev.getSources() & InputDevice.SOURCE_TOUCHPAD) != InputDevice.SOURCE_TOUCHPAD) { + return false; + } + + // Landroid/view/InputDevice;->hasButtonUnderPad()Z is blocked after O + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.O) { + try { + return (Boolean) dev.getClass().getMethod("hasButtonUnderPad").invoke(dev); + } catch (NoSuchMethodException e) { + e.printStackTrace(); + } catch (IllegalAccessException e) { + e.printStackTrace(); + } catch (InvocationTargetException e) { + e.printStackTrace(); + } catch (ClassCastException e) { + e.printStackTrace(); + } + } + + // We can't use the platform API, so we'll have to just guess based on the gamepad type. + // If this is a PlayStation controller with a touchpad, we know it has a clickpad. + return type == MoonBridge.LI_CTYPE_PS; + } + + private static boolean isExternal(InputDevice dev) { + // The ASUS Tinker Board inaccurately reports Bluetooth gamepads as internal, + // causing shouldIgnoreBack() to believe it should pass through back as a + // navigation event for any attached gamepads. + if (Build.MODEL.equals("Tinker Board")) { + return true; + } + + String deviceName = dev.getName(); + if (deviceName.contains("gpio") || // This is the back button on Shield portable consoles + deviceName.contains("joy_key") || // These are the gamepad buttons on the Archos Gamepad 2 + deviceName.contains("keypad") || // These are gamepad buttons on the XPERIA Play + deviceName.equalsIgnoreCase("NVIDIA Corporation NVIDIA Controller v01.01") || // Gamepad on Shield Portable + deviceName.equalsIgnoreCase("NVIDIA Corporation NVIDIA Controller v01.02") || // Gamepad on Shield Portable (?) + deviceName.equalsIgnoreCase("GR0006") // Gamepad on Logitech G Cloud + ) + { + LimeLog.info(dev.getName()+" is internal by hardcoded mapping"); + return false; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // Landroid/view/InputDevice;->isExternal()Z is officially public on Android Q + return dev.isExternal(); + } + else { + try { + // Landroid/view/InputDevice;->isExternal()Z is on the light graylist in Android P + return (Boolean)dev.getClass().getMethod("isExternal").invoke(dev); + } catch (NoSuchMethodException e) { + e.printStackTrace(); + } catch (IllegalAccessException e) { + e.printStackTrace(); + } catch (InvocationTargetException e) { + e.printStackTrace(); + } catch (ClassCastException e) { + e.printStackTrace(); + } + } + + // Answer true if we don't know + return true; + } + + private boolean shouldIgnoreBack(InputDevice dev) { + String devName = dev.getName(); + + // The Serval has a Select button but the framework doesn't + // know about that because it uses a non-standard scancode. + if (devName.contains("Razer Serval")) { + return true; + } + + // Classify this device as a remote by name if it has no joystick axes + if (!hasJoystickAxes(dev) && devName.toLowerCase().contains("remote")) { + return true; + } + + // Otherwise, dynamically try to determine whether we should allow this + // back button to function for navigation. + // + // First, check if this is an internal device we're being called on. + if (!isExternal(dev)) { + InputManager im = (InputManager) activityContext.getSystemService(Context.INPUT_SERVICE); + + boolean foundInternalGamepad = false; + boolean foundInternalSelect = false; + for (int id : im.getInputDeviceIds()) { + InputDevice currentDev = im.getInputDevice(id); + + // Ignore external devices + if (currentDev == null || isExternal(currentDev)) { + continue; + } + + // Note that we are explicitly NOT excluding the current device we're examining here, + // since the other gamepad buttons may be on our current device and that's fine. + if (currentDev.hasKeys(KeyEvent.KEYCODE_BUTTON_SELECT)[0]) { + foundInternalSelect = true; + } + + // We don't check KEYCODE_BUTTON_A here, since the Shield Android TV has a + // virtual mouse device that claims to have KEYCODE_BUTTON_A. Instead, we rely + // on the SOURCE_GAMEPAD flag to be set on gamepad devices. + if (hasGamepadButtons(currentDev)) { + foundInternalGamepad = true; + } + } + + // Allow the back button to function for navigation if we either: + // a) have no internal gamepad (most phones) + // b) have an internal gamepad but also have an internal select button (GPD XD) + // but not: + // c) have an internal gamepad but no internal select button (NVIDIA SHIELD Portable) + return !foundInternalGamepad || foundInternalSelect; + } + else { + // For external devices, we want to pass through the back button if the device + // has no gamepad axes or gamepad buttons. + return !hasJoystickAxes(dev) && !hasGamepadButtons(dev); + } + } + + private InputDeviceContext createInputDeviceContextForDevice(InputDevice dev) { + InputDeviceContext context = new InputDeviceContext(); + String devName = dev.getName(); + + LimeLog.info("Creating controller context for device: "+devName); + LimeLog.info("Vendor ID: " + dev.getVendorId()); + LimeLog.info("Product ID: "+dev.getProductId()); + LimeLog.info(dev.toString()); + + context.inputDevice = dev; + context.name = devName; + context.id = dev.getId(); + context.external = isExternal(dev); + + context.vendorId = dev.getVendorId(); + context.productId = dev.getProductId(); + + // These aren't always present in the Android key layout files, so they won't show up + // in our normal InputDevice.hasKeys() probing. + context.hasPaddles = MoonBridge.guessControllerHasPaddles(context.vendorId, context.productId); + context.hasShare = MoonBridge.guessControllerHasShareButton(context.vendorId, context.productId); + + if(prefConfig.enableDeviceRumble){ + context.vibrator = deviceVibrator; + }else{ + // Try to use the InputDevice's associated vibrators first + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && hasQuadAmplitudeControlledRumbleVibrators(dev.getVibratorManager())) { + context.vibratorManager = dev.getVibratorManager(); + context.quadVibrators = true; + } + else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && hasDualAmplitudeControlledRumbleVibrators(dev.getVibratorManager())) { + context.vibratorManager = dev.getVibratorManager(); + context.quadVibrators = false; + } + else if (dev.getVibrator().hasVibrator()) { + context.vibrator = dev.getVibrator(); + } + else if (!context.external) { + // If this is an internal controller, try to use the device's vibrator + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && hasQuadAmplitudeControlledRumbleVibrators(deviceVibratorManager)) { + context.vibratorManager = deviceVibratorManager; + context.quadVibrators = true; + } + else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && hasDualAmplitudeControlledRumbleVibrators(deviceVibratorManager)) { + context.vibratorManager = deviceVibratorManager; + context.quadVibrators = false; + } + else if (deviceVibrator.hasVibrator()) { + context.vibrator = deviceVibrator; + } + } + } + // On Android 12, we can try to use the InputDevice's sensors. This may not work if the + // Linux kernel version doesn't have motion sensor support, which is common for third-party + // gamepads. + // + // Android 12 has a bug that causes InputDeviceSensorManager to cause a NPE on a background + // thread due to bad error checking in InputListener callbacks. InputDeviceSensorManager is + // created upon the first call to InputDevice.getSensorManager(), so we avoid calling this + // on Android 12 unless we have a gamepad that could plausibly have motion sensors. + // https://cs.android.com/android/_/android/platform/frameworks/base/+/8970010a5e9f3dc5c069f56b4147552accfcbbeb + if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU || + (Build.VERSION.SDK_INT == Build.VERSION_CODES.S && + (context.vendorId == 0x054c || context.vendorId == 0x057e))) && // Sony or Nintendo + prefConfig.gamepadMotionSensors) { + if (dev.getSensorManager().getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null || dev.getSensorManager().getDefaultSensor(Sensor.TYPE_GYROSCOPE) != null) { + context.sensorManager = dev.getSensorManager(); + } + } + + // Check if this device has a usable RGB LED and cache that result + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + for (Light light : dev.getLightsManager().getLights()) { + if (light.hasRgbControl()) { + context.hasRgbLed = true; + break; + } + } + } + + // Detect if the gamepad has Mode and Select buttons according to the Android key layouts. + // We do this first because other codepaths below may override these defaults. + boolean[] buttons = dev.hasKeys(KeyEvent.KEYCODE_BUTTON_MODE, KeyEvent.KEYCODE_BUTTON_SELECT, KeyEvent.KEYCODE_BACK, 0); + context.hasMode = buttons[0]; + context.hasSelect = buttons[1] || buttons[2]; + + context.touchpadXRange = dev.getMotionRange(MotionEvent.AXIS_X, InputDevice.SOURCE_TOUCHPAD); + context.touchpadYRange = dev.getMotionRange(MotionEvent.AXIS_Y, InputDevice.SOURCE_TOUCHPAD); + context.touchpadPressureRange = dev.getMotionRange(MotionEvent.AXIS_PRESSURE, InputDevice.SOURCE_TOUCHPAD); + + context.leftStickXAxis = MotionEvent.AXIS_X; + context.leftStickYAxis = MotionEvent.AXIS_Y; + if (getMotionRangeForJoystickAxis(dev, context.leftStickXAxis) != null && + getMotionRangeForJoystickAxis(dev, context.leftStickYAxis) != null) { + // This is a gamepad + hasGameController = true; + context.hasJoystickAxes = true; + } + + // This is hack to deal with the Nvidia Shield's modifications that causes the DS4 clickpad + // to work as a duplicate Select button instead of a unique button we can handle separately. + context.isDualShockStandaloneTouchpad = + context.vendorId == 0x054c && // Sony + devName.endsWith(" Touchpad") && + dev.getSources() == (InputDevice.SOURCE_KEYBOARD | InputDevice.SOURCE_MOUSE); + + InputDevice.MotionRange leftTriggerRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_LTRIGGER); + InputDevice.MotionRange rightTriggerRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_RTRIGGER); + InputDevice.MotionRange brakeRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_BRAKE); + InputDevice.MotionRange gasRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_GAS); + InputDevice.MotionRange throttleRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_THROTTLE); + if (leftTriggerRange != null && rightTriggerRange != null) + { + // Some controllers use LTRIGGER and RTRIGGER (like Ouya) + context.leftTriggerAxis = MotionEvent.AXIS_LTRIGGER; + context.rightTriggerAxis = MotionEvent.AXIS_RTRIGGER; + } + else if (brakeRange != null && gasRange != null) + { + // Others use GAS and BRAKE (like Moga) + context.leftTriggerAxis = MotionEvent.AXIS_BRAKE; + context.rightTriggerAxis = MotionEvent.AXIS_GAS; + } + else if (brakeRange != null && throttleRange != null) + { + // Others use THROTTLE and BRAKE (like Xiaomi) + context.leftTriggerAxis = MotionEvent.AXIS_BRAKE; + context.rightTriggerAxis = MotionEvent.AXIS_THROTTLE; + } + else + { + InputDevice.MotionRange rxRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_RX); + InputDevice.MotionRange ryRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_RY); + if (rxRange != null && ryRange != null && devName != null) { + if (dev.getVendorId() == 0x054c) { // Sony + if (dev.hasKeys(KeyEvent.KEYCODE_BUTTON_C)[0]) { + LimeLog.info("Detected non-standard DualShock 4 mapping"); + context.isNonStandardDualShock4 = true; + } else { + LimeLog.info("Detected DualShock 4 (Linux standard mapping)"); + context.usesLinuxGamepadStandardFaceButtons = true; + } + } + + if (context.isNonStandardDualShock4) { + // The old DS4 driver uses RX and RY for triggers + context.leftTriggerAxis = MotionEvent.AXIS_RX; + context.rightTriggerAxis = MotionEvent.AXIS_RY; + + // DS4 has Select and Mode buttons (possibly mapped non-standard) + context.hasSelect = true; + context.hasMode = true; + } + else { + // If it's not a non-standard DS4 controller, it's probably an Xbox controller or + // other sane controller that uses RX and RY for right stick and Z and RZ for triggers. + context.rightStickXAxis = MotionEvent.AXIS_RX; + context.rightStickYAxis = MotionEvent.AXIS_RY; + + // While it's likely that Z and RZ are triggers, we may have digital trigger buttons + // instead. We must check that we actually have Z and RZ axes before assigning them. + if (getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_Z) != null && + getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_RZ) != null) { + context.leftTriggerAxis = MotionEvent.AXIS_Z; + context.rightTriggerAxis = MotionEvent.AXIS_RZ; + } + } + + // Triggers always idle negative on axes that are centered at zero + context.triggersIdleNegative = true; + } + } + + if (context.rightStickXAxis == -1 && context.rightStickYAxis == -1) { + InputDevice.MotionRange zRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_Z); + InputDevice.MotionRange rzRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_RZ); + + // Most other controllers use Z and RZ for the right stick + if (zRange != null && rzRange != null) { + context.rightStickXAxis = MotionEvent.AXIS_Z; + context.rightStickYAxis = MotionEvent.AXIS_RZ; + } + else { + InputDevice.MotionRange rxRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_RX); + InputDevice.MotionRange ryRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_RY); + + // Try RX and RY now + if (rxRange != null && ryRange != null) { + context.rightStickXAxis = MotionEvent.AXIS_RX; + context.rightStickYAxis = MotionEvent.AXIS_RY; + } + } + } + + // Some devices have "hats" for d-pads + InputDevice.MotionRange hatXRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_HAT_X); + InputDevice.MotionRange hatYRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_HAT_Y); + if (hatXRange != null && hatYRange != null) { + context.hatXAxis = MotionEvent.AXIS_HAT_X; + context.hatYAxis = MotionEvent.AXIS_HAT_Y; + } + + if (context.leftStickXAxis != -1 && context.leftStickYAxis != -1) { + context.leftStickDeadzoneRadius = (float) stickDeadzone; + } + + if (context.rightStickXAxis != -1 && context.rightStickYAxis != -1) { + context.rightStickDeadzoneRadius = (float) stickDeadzone; + } + + if (context.leftTriggerAxis != -1 && context.rightTriggerAxis != -1) { + InputDevice.MotionRange ltRange = getMotionRangeForJoystickAxis(dev, context.leftTriggerAxis); + InputDevice.MotionRange rtRange = getMotionRangeForJoystickAxis(dev, context.rightTriggerAxis); + + // It's important to have a valid deadzone so controller packet batching works properly + context.triggerDeadzone = Math.max(Math.abs(ltRange.getFlat()), Math.abs(rtRange.getFlat())); + + // For triggers without (valid) deadzones, we'll use 13% (around XInput's default) + if (context.triggerDeadzone < 0.13f || + context.triggerDeadzone > 0.30f) + { + context.triggerDeadzone = 0.13f; + } + } + + // The ADT-1 controller needs a similar fixup to the ASUS Gamepad + if (dev.getVendorId() == 0x18d1 && dev.getProductId() == 0x2c40) { + context.backIsStart = true; + context.modeIsSelect = true; + context.triggerDeadzone = 0.30f; + context.hasSelect = true; + context.hasMode = false; + } + + context.ignoreBack = shouldIgnoreBack(dev); + + if (devName != null) { + // For the Nexus Player (and probably other ATV devices), we should + // use the back button as start since it doesn't have a start/menu button + // on the controller + if (devName.contains("ASUS Gamepad")) { + boolean[] hasStartKey = dev.hasKeys(KeyEvent.KEYCODE_BUTTON_START, KeyEvent.KEYCODE_MENU, 0); + if (!hasStartKey[0] && !hasStartKey[1]) { + context.backIsStart = true; + context.modeIsSelect = true; + context.hasSelect = true; + context.hasMode = false; + } + + // The ASUS Gamepad has triggers that sit far forward and are prone to false presses + // so we increase the deadzone on them to minimize this + context.triggerDeadzone = 0.30f; + } + // SHIELD controllers will use small stick deadzones + else if (devName.contains("SHIELD") || devName.contains("NVIDIA Controller")) { + // The big Nvidia button on the Shield controllers acts like a Search button. It + // summons the Google Assistant on the Shield TV. On my Pixel 4, it seems to do + // nothing, so we can hijack it to act like a mode button. + if (devName.contains("NVIDIA Controller v01.03") || devName.contains("NVIDIA Controller v01.04")) { + context.searchIsMode = true; + context.hasMode = true; + } + } + // The Serval has a couple of unknown buttons that are start and select. It also has + // a back button which we want to ignore since there's already a select button. + else if (devName.contains("Razer Serval")) { + context.isServal = true; + + // Serval has Select and Mode buttons (possibly mapped non-standard) + context.hasMode = true; + context.hasSelect = true; + } + // The Xbox One S Bluetooth controller has some mappings that need fixing up. + // However, Microsoft released a firmware update with no change to VID/PID + // or device name that fixed the mappings for Android. Since there's + // no good way to detect this, we'll use the presence of GAS/BRAKE axes + // that were added in the latest firmware. If those are present, the only + // required fixup is ignoring the select button. + else if (devName.equals("Xbox Wireless Controller")) { + if (gasRange == null) { + context.isNonStandardXboxBtController = true; + + // Xbox One S has Select and Mode buttons (possibly mapped non-standard) + context.hasMode = true; + context.hasSelect = true; + } + } + } + + // Thrustmaster Score A gamepad home button reports directly to android as + // KEY_HOMEPAGE event on another event channel + if (dev.getVendorId() == 0x044f && dev.getProductId() == 0xb328) { + context.hasMode = false; + } + + LimeLog.info("Analog stick deadzone: "+context.leftStickDeadzoneRadius+" "+context.rightStickDeadzoneRadius); + LimeLog.info("Trigger deadzone: "+context.triggerDeadzone); + + return context; + } + + private InputDeviceContext getContextForEvent(InputEvent event) { + // Don't return a context if we're stopped + if (stopped) { + return null; + } + else if (event.getDeviceId() == 0) { + // Unknown devices use the default context + return defaultContext; + } + else if (event.getDevice() == null) { + // During device removal, sometimes we can get events after the + // input device has been destroyed. In this case we'll see a + // != 0 device ID but no device attached. + return null; + } + + // HACK for https://issuetracker.google.com/issues/163120692 + if (Build.VERSION.SDK_INT == Build.VERSION_CODES.R) { + if (event.getDeviceId() == -1) { + return defaultContext; + } + } + + // Return the existing context if it exists + InputDeviceContext context = inputDeviceContexts.get(event.getDeviceId()); + if (context != null) { + return context; + } + + // Otherwise create a new context + context = createInputDeviceContextForDevice(event.getDevice()); + inputDeviceContexts.put(event.getDeviceId(), context); + + return context; + } + + private byte maxByMagnitude(byte a, byte b) { + int absA = Math.abs(a); + int absB = Math.abs(b); + if (absA > absB) { + return a; + } + else { + return b; + } + } + + private short maxByMagnitude(short a, short b) { + int absA = Math.abs(a); + int absB = Math.abs(b); + if (absA > absB) { + return a; + } + else { + return b; + } + } + + private short getActiveControllerMask() { + if (prefConfig.multiController) { + return (short)(currentControllers | initialControllers | (prefConfig.onscreenController ? 1 : 0)); + } + else { + // Only Player 1 is active with multi-controller disabled + return 1; + } + } + + private static boolean areBatteryCapacitiesEqual(float first, float second) { + // With no NaNs involved, it is a simple equality comparison. + if (!Float.isNaN(first) && !Float.isNaN(second)) { + return first == second; + } + else { + // If we have a NaN in one or both positions, compare NaN-ness instead. + // Equality comparisons will always return false for NaN. + return Float.isNaN(first) == Float.isNaN(second); + } + } + + // This must not be called on the main thread due to risk of ANRs! + private void sendControllerBatteryPacket(InputDeviceContext context) { + int currentBatteryStatus; + float currentBatteryCapacity; + + // Use the BatteryState object introduced in Android S, if it's available and present. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && context.inputDevice.getBatteryState().isPresent()) { + currentBatteryStatus = context.inputDevice.getBatteryState().getStatus(); + currentBatteryCapacity = context.inputDevice.getBatteryState().getCapacity(); + } + else if (sceManager.isRecognizedDevice(context.inputDevice)) { + // On the SHIELD Android TV, we can use a proprietary API to access battery/charge state. + // We will convert it to the same form used by BatteryState to share code. + int batteryPercentage = sceManager.getBatteryPercentage(context.inputDevice); + if (batteryPercentage < 0) { + currentBatteryCapacity = Float.NaN; + } + else { + currentBatteryCapacity = batteryPercentage / 100.f; + } + + SceConnectionType connectionType = sceManager.getConnectionType(context.inputDevice); + SceChargingState chargingState = sceManager.getChargingState(context.inputDevice); + + // We can make some assumptions about charge state based on the connection type + if (connectionType == SceConnectionType.WIRED || connectionType == SceConnectionType.BOTH) { + if (batteryPercentage == 100) { + currentBatteryStatus = BatteryState.STATUS_FULL; + } + else if (chargingState == SceChargingState.NOT_CHARGING) { + currentBatteryStatus = BatteryState.STATUS_NOT_CHARGING; + } + else { + currentBatteryStatus = BatteryState.STATUS_CHARGING; + } + } + else if (connectionType == SceConnectionType.WIRELESS) { + if (chargingState == SceChargingState.CHARGING) { + currentBatteryStatus = BatteryState.STATUS_CHARGING; + } + else { + currentBatteryStatus = BatteryState.STATUS_DISCHARGING; + } + } + else { + // If connection type is unknown, just use the charge state + if (batteryPercentage == 100) { + currentBatteryStatus = BatteryState.STATUS_FULL; + } + else if (chargingState == SceChargingState.NOT_CHARGING) { + currentBatteryStatus = BatteryState.STATUS_DISCHARGING; + } + else if (chargingState == SceChargingState.CHARGING) { + currentBatteryStatus = BatteryState.STATUS_CHARGING; + } + else { + currentBatteryStatus = BatteryState.STATUS_UNKNOWN; + } + } + } + else { + return; + } + + if (currentBatteryStatus != context.lastReportedBatteryStatus || + !areBatteryCapacitiesEqual(currentBatteryCapacity, context.lastReportedBatteryCapacity)) { + byte state; + byte percentage; + + switch (currentBatteryStatus) { + case BatteryState.STATUS_UNKNOWN: + state = MoonBridge.LI_BATTERY_STATE_UNKNOWN; + break; + + case BatteryState.STATUS_CHARGING: + state = MoonBridge.LI_BATTERY_STATE_CHARGING; + break; + + case BatteryState.STATUS_DISCHARGING: + state = MoonBridge.LI_BATTERY_STATE_DISCHARGING; + break; + + case BatteryState.STATUS_NOT_CHARGING: + state = MoonBridge.LI_BATTERY_STATE_NOT_CHARGING; + break; + + case BatteryState.STATUS_FULL: + state = MoonBridge.LI_BATTERY_STATE_FULL; + break; + + default: + return; + } + + if (Float.isNaN(currentBatteryCapacity)) { + percentage = MoonBridge.LI_BATTERY_PERCENTAGE_UNKNOWN; + } + else { + percentage = (byte)(currentBatteryCapacity * 100); + } + + conn.sendControllerBatteryEvent((byte)context.controllerNumber, state, percentage); + + context.lastReportedBatteryStatus = currentBatteryStatus; + context.lastReportedBatteryCapacity = currentBatteryCapacity; + } + } + + private void sendControllerInputPacket(GenericControllerContext originalContext) { + assignControllerNumberIfNeeded(originalContext); + + // Take the context's controller number and fuse all inputs with the same number + short controllerNumber = originalContext.controllerNumber; + int inputMap = 0; + byte leftTrigger = 0; + byte rightTrigger = 0; + short leftStickX = 0; + short leftStickY = 0; + short rightStickX = 0; + short rightStickY = 0; + + // In order to properly handle controllers that are split into multiple devices, + // we must aggregate all controllers with the same controller number into a single + // device before we send it. + for (int i = 0; i < inputDeviceContexts.size(); i++) { + GenericControllerContext context = inputDeviceContexts.valueAt(i); + if (context.assignedControllerNumber && + context.controllerNumber == controllerNumber && + context.mouseEmulationActive == originalContext.mouseEmulationActive) { + inputMap |= context.inputMap; + leftTrigger |= maxByMagnitude(leftTrigger, context.leftTrigger); + rightTrigger |= maxByMagnitude(rightTrigger, context.rightTrigger); + leftStickX |= maxByMagnitude(leftStickX, context.leftStickX); + leftStickY |= maxByMagnitude(leftStickY, context.leftStickY); + rightStickX |= maxByMagnitude(rightStickX, context.rightStickX); + rightStickY |= maxByMagnitude(rightStickY, context.rightStickY); + } + } + for (int i = 0; i < usbDeviceContexts.size(); i++) { + GenericControllerContext context = usbDeviceContexts.valueAt(i); + if (context.assignedControllerNumber && + context.controllerNumber == controllerNumber && + context.mouseEmulationActive == originalContext.mouseEmulationActive) { + inputMap |= context.inputMap; + leftTrigger |= maxByMagnitude(leftTrigger, context.leftTrigger); + rightTrigger |= maxByMagnitude(rightTrigger, context.rightTrigger); + leftStickX |= maxByMagnitude(leftStickX, context.leftStickX); + leftStickY |= maxByMagnitude(leftStickY, context.leftStickY); + rightStickX |= maxByMagnitude(rightStickX, context.rightStickX); + rightStickY |= maxByMagnitude(rightStickY, context.rightStickY); + } + } + if (defaultContext.controllerNumber == controllerNumber) { + inputMap |= defaultContext.inputMap; + leftTrigger |= maxByMagnitude(leftTrigger, defaultContext.leftTrigger); + rightTrigger |= maxByMagnitude(rightTrigger, defaultContext.rightTrigger); + leftStickX |= maxByMagnitude(leftStickX, defaultContext.leftStickX); + leftStickY |= maxByMagnitude(leftStickY, defaultContext.leftStickY); + rightStickX |= maxByMagnitude(rightStickX, defaultContext.rightStickX); + rightStickY |= maxByMagnitude(rightStickY, defaultContext.rightStickY); + } + + if (originalContext.mouseEmulationActive) { + int changedMask = inputMap ^ originalContext.mouseEmulationLastInputMap; + + boolean aDown = (inputMap & ControllerPacket.A_FLAG) != 0; + boolean bDown = (inputMap & ControllerPacket.B_FLAG) != 0; + + originalContext.mouseEmulationLastInputMap = inputMap; + + if ((changedMask & ControllerPacket.A_FLAG) != 0) { + if (aDown) { + conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_LEFT); + } + else { + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT); + } + } + if ((changedMask & ControllerPacket.B_FLAG) != 0) { + if (bDown) { + conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_RIGHT); + } + else { + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_RIGHT); + } + } + if ((changedMask & ControllerPacket.UP_FLAG) != 0) { + if ((inputMap & ControllerPacket.UP_FLAG) != 0) { + conn.sendMouseScroll((byte) 1); + } + } + if ((changedMask & ControllerPacket.DOWN_FLAG) != 0) { + if ((inputMap & ControllerPacket.DOWN_FLAG) != 0) { + conn.sendMouseScroll((byte) -1); + } + } + if ((changedMask & ControllerPacket.RIGHT_FLAG) != 0) { + if ((inputMap & ControllerPacket.RIGHT_FLAG) != 0) { + conn.sendMouseHScroll((byte) 1); + } + } + if ((changedMask & ControllerPacket.LEFT_FLAG) != 0) { + if ((inputMap & ControllerPacket.LEFT_FLAG) != 0) { + conn.sendMouseHScroll((byte) -1); + } + } + + conn.sendControllerInput(controllerNumber, getActiveControllerMask(), + (short)0, (byte)0, (byte)0, (short)0, (short)0, (short)0, (short)0); + } + else { + conn.sendControllerInput(controllerNumber, getActiveControllerMask(), + inputMap, + leftTrigger, rightTrigger, + leftStickX, leftStickY, + rightStickX, rightStickY); + } + } + + private final int REMAP_IGNORE = -1; + private final int REMAP_CONSUME = -2; + + // Return a valid keycode, -2 to consume, or -1 to not consume the event + // Device MAY BE NULL + private int handleRemapping(InputDeviceContext context, KeyEvent event) { + // Don't capture the back button if configured + if (context.ignoreBack) { + if (event.getKeyCode() == KeyEvent.KEYCODE_BACK) { + return REMAP_IGNORE; + } + } + + // If we know this gamepad has a share button and receive an unmapped + // KEY_RECORD event, report that as a share button press. + if (context.hasShare) { + if (event.getKeyCode() == KeyEvent.KEYCODE_UNKNOWN && + event.getScanCode() == 167) { + return KeyEvent.KEYCODE_MEDIA_RECORD; + } + } + + // The Shield's key layout files map the DualShock 4 clickpad button to + // BUTTON_SELECT instead of something sane like BUTTON_1 as the standard AOSP + // mapping does. If we get a button from a Sony device reported as BUTTON_SELECT + // that matches the keycode used by hid-sony for the clickpad or it's from the + // separate touchpad input device, remap it to BUTTON_1 to match the current AOSP + // layout and trigger our touchpad button logic. + if (context.vendorId == 0x054c && + event.getKeyCode() == KeyEvent.KEYCODE_BUTTON_SELECT && + (event.getScanCode() == 317 || context.isDualShockStandaloneTouchpad)) { + return KeyEvent.KEYCODE_BUTTON_1; + } + + // Override mode button for 8BitDo controllers + if (context.vendorId == 0x2dc8 && event.getScanCode() == 306) { + return KeyEvent.KEYCODE_BUTTON_MODE; + } + + // This mapping was adding in Android 10, then changed based on + // kernel changes (adding hid-nintendo) in Android 11. If we're + // on anything newer than Pie, just use the built-in mapping. + if ((context.vendorId == 0x057e && context.productId == 0x2009 && Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) || // Switch Pro controller + (context.vendorId == 0x0f0d && context.productId == 0x00c1)) { // HORIPAD for Switch + switch (event.getScanCode()) { + case 0x130://304 + return KeyEvent.KEYCODE_BUTTON_A; + case 0x131: + return KeyEvent.KEYCODE_BUTTON_B; + case 0x132: + return KeyEvent.KEYCODE_BUTTON_X; + case 0x133: + return KeyEvent.KEYCODE_BUTTON_Y; + case 0x134: + return KeyEvent.KEYCODE_BUTTON_L1; + case 0x135: + return KeyEvent.KEYCODE_BUTTON_R1; + case 0x136: + return KeyEvent.KEYCODE_BUTTON_L2; + case 0x137: + return KeyEvent.KEYCODE_BUTTON_R2; + case 0x138: + return KeyEvent.KEYCODE_BUTTON_SELECT; + case 0x139: + return KeyEvent.KEYCODE_BUTTON_START; + case 0x13A: + return KeyEvent.KEYCODE_BUTTON_THUMBL; + case 0x13B: + return KeyEvent.KEYCODE_BUTTON_THUMBR; + case 0x13D: + return KeyEvent.KEYCODE_BUTTON_MODE; + } + } + + + //fix joycon-left 十字键 + if(prefConfig.enableJoyConFix&&context.vendorId == 0x057e && context.productId == 0x2006){ + switch (event.getScanCode()) + { + case 546://十字键 + return KeyEvent.KEYCODE_DPAD_LEFT; + case 547: + return KeyEvent.KEYCODE_DPAD_RIGHT; + case 544: + return KeyEvent.KEYCODE_DPAD_UP; + case 545: + return KeyEvent.KEYCODE_DPAD_DOWN; + case 309://截图键 + return KeyEvent.KEYCODE_BUTTON_MODE; + case 310: + return KeyEvent.KEYCODE_BUTTON_L1; + case 312: + return KeyEvent.KEYCODE_BUTTON_L2; + case 314: + return KeyEvent.KEYCODE_BUTTON_SELECT; + case 317: + return KeyEvent.KEYCODE_BUTTON_THUMBL; + } + } + //fix JoyCon-right xy互换 + if(prefConfig.enableJoyConFix&&context.vendorId == 0x057e && context.productId == 0x2007){ + switch (event.getScanCode()) + { + case 307://XY相反 + return KeyEvent.KEYCODE_BUTTON_Y; + case 308: + return KeyEvent.KEYCODE_BUTTON_X; + case 304: + return KeyEvent.KEYCODE_BUTTON_A; + case 305: + return KeyEvent.KEYCODE_BUTTON_B; + case 311: + return KeyEvent.KEYCODE_BUTTON_R1; + case 313: + return KeyEvent.KEYCODE_BUTTON_R2; + case 315: + return KeyEvent.KEYCODE_BUTTON_START; + case 316: + return KeyEvent.KEYCODE_BUTTON_MODE; + case 318: + return KeyEvent.KEYCODE_BUTTON_THUMBR; + } + } + + + if (context.usesLinuxGamepadStandardFaceButtons) { + // Android's Generic.kl swaps BTN_NORTH and BTN_WEST + switch (event.getScanCode()) { + case 304: + return KeyEvent.KEYCODE_BUTTON_A; + case 305: + return KeyEvent.KEYCODE_BUTTON_B; + case 307: + return KeyEvent.KEYCODE_BUTTON_Y; + case 308: + return KeyEvent.KEYCODE_BUTTON_X; + } + } + + if (context.isNonStandardDualShock4) { + switch (event.getScanCode()) { + case 304: + return KeyEvent.KEYCODE_BUTTON_X; + case 305: + return KeyEvent.KEYCODE_BUTTON_A; + case 306: + return KeyEvent.KEYCODE_BUTTON_B; + case 307: + return KeyEvent.KEYCODE_BUTTON_Y; + case 308: + return KeyEvent.KEYCODE_BUTTON_L1; + case 309: + return KeyEvent.KEYCODE_BUTTON_R1; + /* + **** Using analog triggers instead **** + case 310: + return KeyEvent.KEYCODE_BUTTON_L2; + case 311: + return KeyEvent.KEYCODE_BUTTON_R2; + */ + case 312: + return KeyEvent.KEYCODE_BUTTON_SELECT; + case 313: + return KeyEvent.KEYCODE_BUTTON_START; + case 314: + return KeyEvent.KEYCODE_BUTTON_THUMBL; + case 315: + return KeyEvent.KEYCODE_BUTTON_THUMBR; + case 316: + return KeyEvent.KEYCODE_BUTTON_MODE; + default: + return REMAP_CONSUME; + } + } + // If this is a Serval controller sending an unknown key code, it's probably + // the start and select buttons + else if (context.isServal && event.getKeyCode() == KeyEvent.KEYCODE_UNKNOWN) { + switch (event.getScanCode()) { + case 314: + return KeyEvent.KEYCODE_BUTTON_SELECT; + case 315: + return KeyEvent.KEYCODE_BUTTON_START; + } + } + else if (context.isNonStandardXboxBtController) { + switch (event.getScanCode()) { + case 306: + return KeyEvent.KEYCODE_BUTTON_X; + case 307: + return KeyEvent.KEYCODE_BUTTON_Y; + case 308: + return KeyEvent.KEYCODE_BUTTON_L1; + case 309: + return KeyEvent.KEYCODE_BUTTON_R1; + case 310: + return KeyEvent.KEYCODE_BUTTON_SELECT; + case 311: + return KeyEvent.KEYCODE_BUTTON_START; + case 312: + return KeyEvent.KEYCODE_BUTTON_THUMBL; + case 313: + return KeyEvent.KEYCODE_BUTTON_THUMBR; + case 139: + return KeyEvent.KEYCODE_BUTTON_MODE; + default: + // Other buttons are mapped correctly + } + + // The Xbox button is sent as MENU + if (event.getKeyCode() == KeyEvent.KEYCODE_MENU) { + return KeyEvent.KEYCODE_BUTTON_MODE; + } + } + else if (context.vendorId == 0x0b05 && // ASUS + (context.productId == 0x7900 || // Kunai - USB + context.productId == 0x7902)) // Kunai - Bluetooth + { + // ROG Kunai has special M1-M4 buttons that are accessible via the + // joycon-style detachable controllers that we should map to Start + // and Select. + switch (event.getScanCode()) { + case 264: + case 266: + return KeyEvent.KEYCODE_BUTTON_START; + + case 265: + case 267: + return KeyEvent.KEYCODE_BUTTON_SELECT; + } + } + + if (context.hatXAxis == -1 && + context.hatYAxis == -1 && + /* FIXME: There's no good way to know for sure if xpad is bound + to this device, so we won't use the name to validate if these + scancodes should be mapped to DPAD + + context.isXboxController && + */ + event.getKeyCode() == KeyEvent.KEYCODE_UNKNOWN) { + // If there's not a proper Xbox controller mapping, we'll translate the raw d-pad + // scan codes into proper key codes + switch (event.getScanCode()) + { + case 704: + return KeyEvent.KEYCODE_DPAD_LEFT; + case 705: + return KeyEvent.KEYCODE_DPAD_RIGHT; + case 706: + return KeyEvent.KEYCODE_DPAD_UP; + case 707: + return KeyEvent.KEYCODE_DPAD_DOWN; + } + } + + // Past here we can fixup the keycode and potentially trigger + // another special case so we need to remember what keycode we're using + int keyCode = event.getKeyCode(); + + // This is a hack for (at least) the "Tablet Remote" app + // which sends BACK with META_ALT_ON instead of KEYCODE_BUTTON_B + if (keyCode == KeyEvent.KEYCODE_BACK && + !event.hasNoModifiers() && + (event.getFlags() & KeyEvent.FLAG_SOFT_KEYBOARD) != 0) + { + keyCode = KeyEvent.KEYCODE_BUTTON_B; + } + + if (keyCode == KeyEvent.KEYCODE_BUTTON_START || + keyCode == KeyEvent.KEYCODE_MENU) { + // Ensure that we never use back as start if we have a real start + context.backIsStart = false; + } + else if (keyCode == KeyEvent.KEYCODE_BUTTON_SELECT) { + // Don't use mode as select if we have a select + context.modeIsSelect = false; + } + else if (context.backIsStart && keyCode == KeyEvent.KEYCODE_BACK) { + // Emulate the start button with back + return KeyEvent.KEYCODE_BUTTON_START; + } + else if (context.modeIsSelect && keyCode == KeyEvent.KEYCODE_BUTTON_MODE) { + // Emulate the select button with mode + return KeyEvent.KEYCODE_BUTTON_SELECT; + } + else if (context.searchIsMode && keyCode == KeyEvent.KEYCODE_SEARCH) { + // Emulate the mode button with search + return KeyEvent.KEYCODE_BUTTON_MODE; + } + + return keyCode; + } + + private int handleFlipFaceButtons(int keyCode) { + switch (keyCode) { + case KeyEvent.KEYCODE_BUTTON_A: + return KeyEvent.KEYCODE_BUTTON_B; + case KeyEvent.KEYCODE_BUTTON_B: + return KeyEvent.KEYCODE_BUTTON_A; + case KeyEvent.KEYCODE_BUTTON_X: + return KeyEvent.KEYCODE_BUTTON_Y; + case KeyEvent.KEYCODE_BUTTON_Y: + return KeyEvent.KEYCODE_BUTTON_X; + default: + return keyCode; + } + } + + private Vector2d populateCachedVector(float x, float y) { + // Reinitialize our cached Vector2d object + inputVector.initialize(x, y); + return inputVector; + } + + private void handleDeadZone(Vector2d stickVector, float deadzoneRadius) { + if (stickVector.getMagnitude() <= deadzoneRadius) { + // Deadzone + stickVector.initialize(0, 0); + } + + // We're not normalizing here because we let the computer handle the deadzones. + // Normalizing can make the deadzones larger than they should be after the computer also + // evaluates the deadzone. + } + + private void handleAxisSet(InputDeviceContext context, float lsX, float lsY, float rsX, + float rsY, float lt, float rt, float hatX, float hatY) { + + if (context.leftStickXAxis != -1 && context.leftStickYAxis != -1) { + Vector2d leftStickVector = populateCachedVector(lsX, lsY); + + handleDeadZone(leftStickVector, context.leftStickDeadzoneRadius); + + context.leftStickX = (short) (leftStickVector.getX() * 0x7FFE); + context.leftStickY = (short) (-leftStickVector.getY() * 0x7FFE); + } + + if (context.rightStickXAxis != -1 && context.rightStickYAxis != -1) { + Vector2d rightStickVector = populateCachedVector(rsX, rsY); + + handleDeadZone(rightStickVector, context.rightStickDeadzoneRadius); + + context.rightStickX = (short) (rightStickVector.getX() * 0x7FFE); + context.rightStickY = (short) (-rightStickVector.getY() * 0x7FFE); + } + + if (context.leftTriggerAxis != -1 && context.rightTriggerAxis != -1) { + // Android sends an initial 0 value for trigger axes even if the trigger + // should be negative when idle. After the first touch, the axes will go back + // to normal behavior, so ignore triggersIdleNegative for each trigger until + // first touch. + if (lt != 0) { + context.leftTriggerAxisUsed = true; + } + if (rt != 0) { + context.rightTriggerAxisUsed = true; + } + if (context.triggersIdleNegative) { + if (context.leftTriggerAxisUsed) { + lt = (lt + 1) / 2; + } + if (context.rightTriggerAxisUsed) { + rt = (rt + 1) / 2; + } + } + + if (lt <= context.triggerDeadzone) { + lt = 0; + } + if (rt <= context.triggerDeadzone) { + rt = 0; + } + + context.leftTrigger = (byte)(lt * 0xFF); + context.rightTrigger = (byte)(rt * 0xFF); + } + + if (context.hatXAxis != -1 && context.hatYAxis != -1) { + context.inputMap &= ~(ControllerPacket.LEFT_FLAG | ControllerPacket.RIGHT_FLAG); + if (hatX < -0.5) { + context.inputMap |= ControllerPacket.LEFT_FLAG; + context.hatXAxisUsed = true; + } + else if (hatX > 0.5) { + context.inputMap |= ControllerPacket.RIGHT_FLAG; + context.hatXAxisUsed = true; + } + + context.inputMap &= ~(ControllerPacket.UP_FLAG | ControllerPacket.DOWN_FLAG); + if (hatY < -0.5) { + context.inputMap |= ControllerPacket.UP_FLAG; + context.hatYAxisUsed = true; + } + else if (hatY > 0.5) { + context.inputMap |= ControllerPacket.DOWN_FLAG; + context.hatYAxisUsed = true; + } + } + + sendControllerInputPacket(context); + } + + // Normalize the given raw float value into a 0.0-1.0f range + private float normalizeRawValueWithRange(float value, InputDevice.MotionRange range) { + value = Math.max(value, range.getMin()); + value = Math.min(value, range.getMax()); + + value -= range.getMin(); + + return value / range.getRange(); + } + + private boolean sendTouchpadEventForPointer(InputDeviceContext context, MotionEvent event, byte touchType, int pointerIndex) { + float normalizedX = normalizeRawValueWithRange(event.getX(pointerIndex), context.touchpadXRange); + float normalizedY = normalizeRawValueWithRange(event.getY(pointerIndex), context.touchpadYRange); + float normalizedPressure = context.touchpadPressureRange != null ? + normalizeRawValueWithRange(event.getPressure(pointerIndex), context.touchpadPressureRange) + : 0; + + return conn.sendControllerTouchEvent((byte)context.controllerNumber, touchType, + event.getPointerId(pointerIndex), + normalizedX, normalizedY, normalizedPressure) != MoonBridge.LI_ERR_UNSUPPORTED; + } + + public boolean tryHandleTouchpadEvent(MotionEvent event) { + // Bail if this is not a touchpad or mouse event + if (event.getSource() != InputDevice.SOURCE_TOUCHPAD && + event.getSource() != InputDevice.SOURCE_MOUSE) { + return false; + } + + // Only get a context if one already exists. We want to ensure we don't report non-gamepads. + InputDeviceContext context = inputDeviceContexts.get(event.getDeviceId()); + if (context == null) { + return false; + } + + // When we're working with a mouse source instead of a touchpad, we're quite limited in + // what useful input we can provide via the controller API. The ABS_X/ABS_Y values are + // screen coordinates rather than touchpad coordinates. For now, we will just support + // the clickpad button and nothing else. + if (event.getSource() == InputDevice.SOURCE_MOUSE) { + // Unlike the touchpad where down and up refer to individual touches on the touchpad, + // down and up on a mouse indicates the state of the left mouse button. + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: + context.inputMap |= ControllerPacket.TOUCHPAD_FLAG; + sendControllerInputPacket(context); + break; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + context.inputMap &= ~ControllerPacket.TOUCHPAD_FLAG; + sendControllerInputPacket(context); + break; + default: + break; + } + + return !prefConfig.gamepadTouchpadAsMouse; + } + + byte touchType; + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: + case MotionEvent.ACTION_POINTER_DOWN: + touchType = MoonBridge.LI_TOUCH_EVENT_DOWN; + break; + + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_POINTER_UP: + if ((event.getFlags() & MotionEvent.FLAG_CANCELED) != 0) { + touchType = MoonBridge.LI_TOUCH_EVENT_CANCEL; + } + else { + touchType = MoonBridge.LI_TOUCH_EVENT_UP; + } + break; + + case MotionEvent.ACTION_MOVE: + touchType = MoonBridge.LI_TOUCH_EVENT_MOVE; + break; + + case MotionEvent.ACTION_CANCEL: + // ACTION_CANCEL applies to *all* pointers in the gesture, so it maps to CANCEL_ALL + // rather than CANCEL. For a single pointer cancellation, that's indicated via + // FLAG_CANCELED on a ACTION_POINTER_UP. + // https://developer.android.com/develop/ui/views/touch-and-input/gestures/multi + touchType = MoonBridge.LI_TOUCH_EVENT_CANCEL_ALL; + break; + + case MotionEvent.ACTION_BUTTON_PRESS: + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && event.getActionButton() == MotionEvent.BUTTON_PRIMARY) { + context.inputMap |= ControllerPacket.TOUCHPAD_FLAG; + sendControllerInputPacket(context); + return !prefConfig.gamepadTouchpadAsMouse; // Report as unhandled event to trigger mouse handling + } + return false; + + case MotionEvent.ACTION_BUTTON_RELEASE: + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && event.getActionButton() == MotionEvent.BUTTON_PRIMARY) { + context.inputMap &= ~ControllerPacket.TOUCHPAD_FLAG; + sendControllerInputPacket(context); + return !prefConfig.gamepadTouchpadAsMouse; // Report as unhandled event to trigger mouse handling + } + return false; + + default: + return false; + } + + // Bail if the user wants gamepad touchpads to control the mouse + // + // NB: We do this after processing ACTION_BUTTON_PRESS and ACTION_BUTTON_RELEASE + // because we want to still send the touchpad button via the gamepad even when + // configured to use the touchpad for mouse control. + if (prefConfig.gamepadTouchpadAsMouse) { + return false; + } + + // If we don't have X and Y ranges, we can't process this event + if (context.touchpadXRange == null || context.touchpadYRange == null) { + return false; + } + + if (event.getActionMasked() == MotionEvent.ACTION_MOVE) { + // Move events may impact all active pointers + for (int i = 0; i < event.getPointerCount(); i++) { + if (!sendTouchpadEventForPointer(context, event, touchType, i)) { + // Controller touch events are not supported by the host + return false; + } + } + return true; + } + else if (event.getActionMasked() == MotionEvent.ACTION_CANCEL) { + // Cancel impacts all active pointers + return conn.sendControllerTouchEvent((byte)context.controllerNumber, MoonBridge.LI_TOUCH_EVENT_CANCEL_ALL, + 0, 0, 0, 0) != MoonBridge.LI_ERR_UNSUPPORTED; + } + else { + // Down and Up events impact the action index pointer + return sendTouchpadEventForPointer(context, event, touchType, event.getActionIndex()); + } + } + + public boolean handleMotionEvent(MotionEvent event) { + InputDeviceContext context = getContextForEvent(event); + if (context == null) { + return true; + } + + float lsX = 0, lsY = 0, rsX = 0, rsY = 0, rt = 0, lt = 0, hatX = 0, hatY = 0; + + // We purposefully ignore the historical values in the motion event as it makes + // the controller feel sluggish for some users. + + if (context.leftStickXAxis != -1 && context.leftStickYAxis != -1) { + lsX = event.getAxisValue(context.leftStickXAxis); + lsY = event.getAxisValue(context.leftStickYAxis); + } + + if (context.rightStickXAxis != -1 && context.rightStickYAxis != -1) { + rsX = event.getAxisValue(context.rightStickXAxis); + rsY = event.getAxisValue(context.rightStickYAxis); + } + + if (context.leftTriggerAxis != -1 && context.rightTriggerAxis != -1) { + lt = event.getAxisValue(context.leftTriggerAxis); + rt = event.getAxisValue(context.rightTriggerAxis); + } + + if (context.hatXAxis != -1 && context.hatYAxis != -1) { + hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X); + hatY = event.getAxisValue(MotionEvent.AXIS_HAT_Y); + } + + handleAxisSet(context, lsX, lsY, rsX, rsY, lt, rt, hatX, hatY); + + return true; + } + + private Vector2d convertRawStickAxisToPixelMovement(short stickX, short stickY) { + Vector2d vector = new Vector2d(); + vector.initialize(stickX, stickY); + vector.scalarMultiply(1 / 32766.0f); + vector.scalarMultiply(4); + if (vector.getMagnitude() > 0) { + // Move faster as the stick is pressed further from center + vector.scalarMultiply(Math.pow(vector.getMagnitude(), 2)); + } + return vector; + } + + private void sendEmulatedMouseMove(short x, short y) { + Vector2d vector = convertRawStickAxisToPixelMovement(x, y); + if (vector.getMagnitude() >= 1) { + conn.sendMouseMove((short)vector.getX(), (short)-vector.getY()); + } + } + + private void sendEmulatedMouseScroll(short x, short y) { + Vector2d vector = convertRawStickAxisToPixelMovement(x, y); + if (vector.getMagnitude() >= 1) { + conn.sendMouseHighResScroll((short)vector.getY()); + conn.sendMouseHighResHScroll((short)vector.getX()); + } + } + + @TargetApi(31) + private boolean hasDualAmplitudeControlledRumbleVibrators(VibratorManager vm) { + int[] vibratorIds = vm.getVibratorIds(); + + // There must be exactly 2 vibrators on this device + if (vibratorIds.length != 2) { + return false; + } + + // Both vibrators must have amplitude control + for (int vid : vibratorIds) { + if (!vm.getVibrator(vid).hasAmplitudeControl()) { + return false; + } + } + + return true; + } + + // This must only be called if hasDualAmplitudeControlledRumbleVibrators() is true! + @TargetApi(31) + private void rumbleDualVibrators(VibratorManager vm, short lowFreqMotor, short highFreqMotor) { + // Normalize motor values to 0-255 amplitudes for VibrationManager + highFreqMotor = (short)((highFreqMotor >> 8) & 0xFF); + lowFreqMotor = (short)((lowFreqMotor >> 8) & 0xFF); + + // If they're both zero, we can just call cancel(). + if (lowFreqMotor == 0 && highFreqMotor == 0) { + vm.cancel(); + return; + } + + // There's no documentation that states that vibrators for FF_RUMBLE input devices will + // always be enumerated in this order, but it seems consistent between Xbox Series X (USB), + // PS3 (USB), and PS4 (USB+BT) controllers on Android 12 Beta 3. + int[] vibratorIds = vm.getVibratorIds(); + int[] vibratorAmplitudes = new int[] { highFreqMotor, lowFreqMotor }; + + CombinedVibration.ParallelCombination combo = CombinedVibration.startParallel(); + + for (int i = 0; i < vibratorIds.length; i++) { + // It's illegal to create a VibrationEffect with an amplitude of 0. + // Simply excluding that vibrator from our ParallelCombination will turn it off. + if (vibratorAmplitudes[i] != 0) { + combo.addVibrator(vibratorIds[i], VibrationEffect.createOneShot(60000, vibratorAmplitudes[i])); + } + } + + VibrationAttributes.Builder vibrationAttributes = new VibrationAttributes.Builder(); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + vibrationAttributes.setUsage(VibrationAttributes.USAGE_MEDIA); + } + + vm.vibrate(combo.combine(), vibrationAttributes.build()); + } + + @TargetApi(31) + private boolean hasQuadAmplitudeControlledRumbleVibrators(VibratorManager vm) { + int[] vibratorIds = vm.getVibratorIds(); + + // There must be exactly 4 vibrators on this device + if (vibratorIds.length != 4) { + return false; + } + + // All vibrators must have amplitude control + for (int vid : vibratorIds) { + if (!vm.getVibrator(vid).hasAmplitudeControl()) { + return false; + } + } + + return true; + } + + // This must only be called if hasQuadAmplitudeControlledRumbleVibrators() is true! + @TargetApi(31) + private void rumbleQuadVibrators(VibratorManager vm, short lowFreqMotor, short highFreqMotor, short leftTrigger, short rightTrigger) { + // Normalize motor values to 0-255 amplitudes for VibrationManager + highFreqMotor = (short)((highFreqMotor >> 8) & 0xFF); + lowFreqMotor = (short)((lowFreqMotor >> 8) & 0xFF); + leftTrigger = (short)((leftTrigger >> 8) & 0xFF); + rightTrigger = (short)((rightTrigger >> 8) & 0xFF); + + // If they're all zero, we can just call cancel(). + if (lowFreqMotor == 0 && highFreqMotor == 0 && leftTrigger == 0 && rightTrigger == 0) { + vm.cancel(); + return; + } + + // This is a guess based upon the behavior of FF_RUMBLE, but untested due to lack of Linux + // support for trigger rumble! + int[] vibratorIds = vm.getVibratorIds(); + int[] vibratorAmplitudes = new int[] { highFreqMotor, lowFreqMotor, leftTrigger, rightTrigger }; + + CombinedVibration.ParallelCombination combo = CombinedVibration.startParallel(); + + for (int i = 0; i < vibratorIds.length; i++) { + // It's illegal to create a VibrationEffect with an amplitude of 0. + // Simply excluding that vibrator from our ParallelCombination will turn it off. + if (vibratorAmplitudes[i] != 0) { + combo.addVibrator(vibratorIds[i], VibrationEffect.createOneShot(60000, vibratorAmplitudes[i])); + } + } + + VibrationAttributes.Builder vibrationAttributes = new VibrationAttributes.Builder(); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + vibrationAttributes.setUsage(VibrationAttributes.USAGE_MEDIA); + } + + vm.vibrate(combo.combine(), vibrationAttributes.build()); + } + + private void rumbleSingleVibrator(Vibrator vibrator, short lowFreqMotor, short highFreqMotor) { + // Since we can only use a single amplitude value, compute the desired amplitude + // by taking 80% of the big motor and 33% of the small motor, then capping to 255. + // NB: This value is now 0-255 as required by VibrationEffect. + short lowFreqMotorMSB = (short)((lowFreqMotor >> 8) & 0xFF); + short highFreqMotorMSB = (short)((highFreqMotor >> 8) & 0xFF); + int simulatedAmplitude = Math.min(255, (int)((lowFreqMotorMSB * 0.80) + (highFreqMotorMSB * 0.33))); + + if (simulatedAmplitude == 0) { + // This case is easy - just cancel the current effect and get out. + // NB: We cannot simply check lowFreqMotor == highFreqMotor == 0 + // because our simulatedAmplitude could be 0 even though our inputs + // are not (ex: lowFreqMotor == 0 && highFreqMotor == 1). + vibrator.cancel(); + return; + } + + // Attempt to use amplitude-based control if we're on Oreo and the device + // supports amplitude-based vibration control. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (vibrator.hasAmplitudeControl()) { + VibrationEffect effect = VibrationEffect.createOneShot(60000, simulatedAmplitude); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + VibrationAttributes vibrationAttributes = new VibrationAttributes.Builder() + .setUsage(VibrationAttributes.USAGE_MEDIA) + .build(); + vibrator.vibrate(effect, vibrationAttributes); + } + else { + AudioAttributes audioAttributes = new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_GAME) + .build(); + vibrator.vibrate(effect, audioAttributes); + } + return; + } + } + + // If we reach this point, we don't have amplitude controls available, so + // we must emulate it by PWMing the vibration. Ick. + long pwmPeriod = 20; + long onTime = (long)((simulatedAmplitude / 255.0) * pwmPeriod); + long offTime = pwmPeriod - onTime; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + VibrationAttributes vibrationAttributes = new VibrationAttributes.Builder() + .setUsage(VibrationAttributes.USAGE_MEDIA) + .build(); + vibrator.vibrate(VibrationEffect.createWaveform(new long[]{0, onTime, offTime}, 0), vibrationAttributes); + } + else { + AudioAttributes audioAttributes = new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_GAME) + .build(); + vibrator.vibrate(new long[]{0, onTime, offTime}, 0, audioAttributes); + } + } + + public void handleRumble(short controllerNumber, short lowFreqMotor, short highFreqMotor) { + boolean foundMatchingDevice = false; + boolean vibrated = false; + + if (stopped) { + return; + } + + for (int i = 0; i < inputDeviceContexts.size(); i++) { + InputDeviceContext deviceContext = inputDeviceContexts.valueAt(i); + + if (deviceContext.controllerNumber == controllerNumber) { + foundMatchingDevice = true; + + deviceContext.lowFreqMotor = lowFreqMotor; + deviceContext.highFreqMotor = highFreqMotor; + + // Prefer the documented Android 12 rumble API which can handle dual vibrators on PS/Xbox controllers + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && deviceContext.vibratorManager != null) { + vibrated = true; + if (deviceContext.quadVibrators) { + rumbleQuadVibrators(deviceContext.vibratorManager, + deviceContext.lowFreqMotor, deviceContext.highFreqMotor, + deviceContext.leftTriggerMotor, deviceContext.rightTriggerMotor); + } + else { + rumbleDualVibrators(deviceContext.vibratorManager, + deviceContext.lowFreqMotor, deviceContext.highFreqMotor); + } + } + // On Shield devices, we can use their special API to rumble Shield controllers + else if (sceManager.rumble(deviceContext.inputDevice, deviceContext.lowFreqMotor, deviceContext.highFreqMotor)) { + vibrated = true; + } + // If all else fails, we have to try the old Vibrator API + else if (deviceContext.vibrator != null) { + vibrated = true; + rumbleSingleVibrator(deviceContext.vibrator, deviceContext.lowFreqMotor, deviceContext.highFreqMotor); + } + } + } + + for (int i = 0; i < usbDeviceContexts.size(); i++) { + UsbDeviceContext deviceContext = usbDeviceContexts.valueAt(i); + + if (deviceContext.controllerNumber == controllerNumber) { + foundMatchingDevice = vibrated = true; + deviceContext.device.rumble(lowFreqMotor, highFreqMotor); + } + } + + // We may decide to rumble the device for player 1 + if (controllerNumber == 0) { + // If we didn't find a matching device, it must be the on-screen + // controls that triggered the rumble. Vibrate the device if + // the user has requested that behavior. + if (!foundMatchingDevice && prefConfig.onscreenController && !prefConfig.onlyL3R3 && prefConfig.vibrateOsc) { + rumbleSingleVibrator(deviceVibrator, lowFreqMotor, highFreqMotor); + } + else if (foundMatchingDevice && !vibrated && prefConfig.vibrateFallbackToDevice) { + // We found a device to vibrate but it didn't have rumble support. The user + // has requested us to vibrate the device in this case. + + // We cast the unsigned short value to a signed int before multiplying by + // the preferred strength. The resulting value is capped at 65534 before + // we cast it back to a short so it doesn't go above 100%. + short lowFreqMotorAdjusted = (short)(Math.min((((lowFreqMotor & 0xffff) + * prefConfig.vibrateFallbackToDeviceStrength) / 100), Short.MAX_VALUE*2)); + short highFreqMotorAdjusted = (short)(Math.min((((highFreqMotor & 0xffff) + * prefConfig.vibrateFallbackToDeviceStrength) / 100), Short.MAX_VALUE*2)); + + rumbleSingleVibrator(deviceVibrator, lowFreqMotorAdjusted, highFreqMotorAdjusted); + } + } + } + + public void handleRumbleTriggers(short controllerNumber, short leftTrigger, short rightTrigger) { + if (stopped) { + return; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + for (int i = 0; i < inputDeviceContexts.size(); i++) { + InputDeviceContext deviceContext = inputDeviceContexts.valueAt(i); + + if (deviceContext.controllerNumber == controllerNumber) { + deviceContext.leftTriggerMotor = leftTrigger; + deviceContext.rightTriggerMotor = rightTrigger; + + if (deviceContext.quadVibrators) { + rumbleQuadVibrators(deviceContext.vibratorManager, + deviceContext.lowFreqMotor, deviceContext.highFreqMotor, + deviceContext.leftTriggerMotor, deviceContext.rightTriggerMotor); + } + } + } + } + + for (int i = 0; i < usbDeviceContexts.size(); i++) { + UsbDeviceContext deviceContext = usbDeviceContexts.valueAt(i); + + if (deviceContext.controllerNumber == controllerNumber) { + deviceContext.device.rumbleTriggers(leftTrigger, rightTrigger); + } + } + } + + private SensorEventListener createSensorListener(final short controllerNumber, final byte motionType, final boolean needsDeviceOrientationCorrection) { + return new SensorEventListener() { + private float[] lastValues = new float[3]; + + @Override + public void onSensorChanged(SensorEvent sensorEvent) { + // Android will invoke our callback any time we get a new reading, + // even if the values are the same as last time. Don't report a + // duplicate set of values to save bandwidth. + if (sensorEvent.values[0] == lastValues[0] && + sensorEvent.values[1] == lastValues[1] && + sensorEvent.values[2] == lastValues[2]) { + return; + } + else { + lastValues[0] = sensorEvent.values[0]; + lastValues[1] = sensorEvent.values[1]; + lastValues[2] = sensorEvent.values[2]; + } + + int x = 0; + int y = 1; + int z = 2; + int xFactor = 1; + int yFactor = 1; + int zFactor = 1; + + if (needsDeviceOrientationCorrection) { + int deviceRotation = activityContext.getWindowManager().getDefaultDisplay().getRotation(); + switch (deviceRotation) { + case Surface.ROTATION_0: + case Surface.ROTATION_180: + x = 0; + y = 2; + z = 1; + break; + + case Surface.ROTATION_90: + case Surface.ROTATION_270: + x = 1; + y = 2; + z = 0; + break; + } + + switch (deviceRotation) { + case Surface.ROTATION_0: + zFactor = -1; + break; + case Surface.ROTATION_90: + xFactor = -1; + zFactor = -1; + break; + case Surface.ROTATION_180: + xFactor = -1; + break; + case Surface.ROTATION_270: + break; + } + } + + if (motionType == MoonBridge.LI_MOTION_TYPE_GYRO) { + // Convert from rad/s to deg/s + conn.sendControllerMotionEvent((byte) controllerNumber, + motionType, + sensorEvent.values[x] * xFactor * 57.2957795f, + sensorEvent.values[y] * yFactor * 57.2957795f, + sensorEvent.values[z] * zFactor * 57.2957795f); + } + else { + // Pass m/s^2 directly without conversion + conn.sendControllerMotionEvent((byte) controllerNumber, + motionType, + sensorEvent.values[x] * xFactor, + sensorEvent.values[y] * yFactor, + sensorEvent.values[z] * zFactor); + } + } + + @Override + public void onAccuracyChanged(Sensor sensor, int accuracy) {} + }; + } + + public void handleSetMotionEventState(final short controllerNumber, final byte motionType, short reportRateHz) { + if (stopped) { + return; + } + + // Report rate is restricted to <= 200 Hz without the HIGH_SAMPLING_RATE_SENSORS permission + reportRateHz = (short) Math.min(200, reportRateHz); + + for (int i = 0; i < inputDeviceContexts.size(); i++) { + InputDeviceContext deviceContext = inputDeviceContexts.valueAt(i); + + if (deviceContext.controllerNumber == controllerNumber) { + // Store the desired report rate even if we don't have sensors. In some cases, + // input devices can be reconfigured at runtime which results in a change where + // sensors disappear and reappear. By storing the desired report rate, we can + // reapply the desired motion sensor configuration after they reappear. + switch (motionType) { + case MoonBridge.LI_MOTION_TYPE_ACCEL: + deviceContext.accelReportRateHz = reportRateHz; + break; + case MoonBridge.LI_MOTION_TYPE_GYRO: + deviceContext.gyroReportRateHz = reportRateHz; + break; + } + + backgroundThreadHandler.removeCallbacks(deviceContext.enableSensorRunnable); + + SensorManager sm = deviceContext.sensorManager; + if (sm == null) { + continue; + } + + switch (motionType) { + case MoonBridge.LI_MOTION_TYPE_ACCEL: + if (deviceContext.accelListener != null) { + sm.unregisterListener(deviceContext.accelListener); + deviceContext.accelListener = null; + } + + // Enable the accelerometer if requested + Sensor accelSensor = sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER); + if (reportRateHz != 0 && accelSensor != null) { + deviceContext.accelListener = createSensorListener(controllerNumber, motionType, sm == deviceSensorManager); + sm.registerListener(deviceContext.accelListener, accelSensor, 1000000 / reportRateHz); + } + break; + case MoonBridge.LI_MOTION_TYPE_GYRO: + if (deviceContext.gyroListener != null) { + sm.unregisterListener(deviceContext.gyroListener); + deviceContext.gyroListener = null; + } + + // Enable the gyroscope if requested + Sensor gyroSensor = sm.getDefaultSensor(Sensor.TYPE_GYROSCOPE); + if (reportRateHz != 0 && gyroSensor != null) { + deviceContext.gyroListener = createSensorListener(controllerNumber, motionType, sm == deviceSensorManager); + sm.registerListener(deviceContext.gyroListener, gyroSensor, 1000000 / reportRateHz); + } + break; + } + break; + } + } + } + + public void handleSetControllerLED(short controllerNumber, byte r, byte g, byte b) { + if (stopped) { + return; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + for (int i = 0; i < inputDeviceContexts.size(); i++) { + InputDeviceContext deviceContext = inputDeviceContexts.valueAt(i); + + // Ignore input devices without an RGB LED + if (deviceContext.controllerNumber == controllerNumber && deviceContext.hasRgbLed) { + // Create a new light session if one doesn't already exist + if (deviceContext.lightsSession == null) { + deviceContext.lightsSession = deviceContext.inputDevice.getLightsManager().openSession(); + } + + // Convert the RGB components into the integer value that LightState uses + int argbValue = 0xFF000000 | ((r << 16) & 0xFF0000) | ((g << 8) & 0xFF00) | (b & 0xFF); + LightState lightState = new LightState.Builder().setColor(argbValue).build(); + + // Set the RGB value for each RGB-controllable LED on the device + LightsRequest.Builder lightsRequestBuilder = new LightsRequest.Builder(); + for (Light light : deviceContext.inputDevice.getLightsManager().getLights()) { + if (light.hasRgbControl()) { + lightsRequestBuilder.addLight(light, lightState); + } + } + + // Apply the LED changes + deviceContext.lightsSession.requestLights(lightsRequestBuilder.build()); + } + } + } + } + + public boolean handleButtonUp(KeyEvent event) { + InputDeviceContext context = getContextForEvent(event); + if (context == null) { + return true; + } + + int keyCode = handleRemapping(context, event); + if (keyCode < 0) { + return (keyCode == REMAP_CONSUME); + } + + if (prefConfig.flipFaceButtons) { + keyCode = handleFlipFaceButtons(keyCode); + } + + // If the button hasn't been down long enough, sleep for a bit before sending the up event + // This allows "instant" button presses (like OUYA's virtual menu button) to work. This + // path should not be triggered during normal usage. + int buttonDownTime = (int)(event.getEventTime() - event.getDownTime()); + if (buttonDownTime < ControllerHandler.MINIMUM_BUTTON_DOWN_TIME_MS) + { + // Since our sleep time is so short (<= 25 ms), it shouldn't cause a problem doing this + // in the UI thread. + try { + Thread.sleep(ControllerHandler.MINIMUM_BUTTON_DOWN_TIME_MS - buttonDownTime); + } catch (InterruptedException e) { + e.printStackTrace(); + + // InterruptedException clears the thread's interrupt status. Since we can't + // handle that here, we will re-interrupt the thread to set the interrupt + // status back to true. + Thread.currentThread().interrupt(); + } + } + + switch (keyCode) { + case KeyEvent.KEYCODE_BUTTON_MODE: + context.inputMap &= ~ControllerPacket.SPECIAL_BUTTON_FLAG; + break; + case KeyEvent.KEYCODE_BUTTON_START: + case KeyEvent.KEYCODE_MENU: + // Sometimes we'll get a spurious key up event on controller disconnect. + // Make sure it's real by checking that the key is actually down before taking + // any action. + if ((context.inputMap & ControllerPacket.PLAY_FLAG) != 0 && + event.getEventTime() - context.startDownTime > ControllerHandler.START_DOWN_TIME_MOUSE_MODE_MS && + prefConfig.mouseEmulation) { + if(prefConfig.enableQtDialog){ + //todo 展示快捷菜单 + gestures.showGameMenu(context); + }else{ + context.toggleMouseEmulation(); + } + } + context.inputMap &= ~ControllerPacket.PLAY_FLAG; + break; + case KeyEvent.KEYCODE_BACK: + case KeyEvent.KEYCODE_BUTTON_SELECT: + context.inputMap &= ~ControllerPacket.BACK_FLAG; + break; + case KeyEvent.KEYCODE_DPAD_LEFT: + if (context.hatXAxisUsed) { + // Suppress this duplicate event if we have a hat + return true; + } + context.inputMap &= ~ControllerPacket.LEFT_FLAG; + break; + case KeyEvent.KEYCODE_DPAD_RIGHT: + if (context.hatXAxisUsed) { + // Suppress this duplicate event if we have a hat + return true; + } + context.inputMap &= ~ControllerPacket.RIGHT_FLAG; + break; + case KeyEvent.KEYCODE_DPAD_UP: + if (context.hatYAxisUsed) { + // Suppress this duplicate event if we have a hat + return true; + } + context.inputMap &= ~ControllerPacket.UP_FLAG; + break; + case KeyEvent.KEYCODE_DPAD_DOWN: + if (context.hatYAxisUsed) { + // Suppress this duplicate event if we have a hat + return true; + } + context.inputMap &= ~ControllerPacket.DOWN_FLAG; + break; + case KeyEvent.KEYCODE_DPAD_UP_LEFT: + if (context.hatXAxisUsed && context.hatYAxisUsed) { + // Suppress this duplicate event if we have a hat + return true; + } + context.inputMap &= ~(ControllerPacket.UP_FLAG | ControllerPacket.LEFT_FLAG); + break; + case KeyEvent.KEYCODE_DPAD_UP_RIGHT: + if (context.hatXAxisUsed && context.hatYAxisUsed) { + // Suppress this duplicate event if we have a hat + return true; + } + context.inputMap &= ~(ControllerPacket.UP_FLAG | ControllerPacket.RIGHT_FLAG); + break; + case KeyEvent.KEYCODE_DPAD_DOWN_LEFT: + if (context.hatXAxisUsed && context.hatYAxisUsed) { + // Suppress this duplicate event if we have a hat + return true; + } + context.inputMap &= ~(ControllerPacket.DOWN_FLAG | ControllerPacket.LEFT_FLAG); + break; + case KeyEvent.KEYCODE_DPAD_DOWN_RIGHT: + if (context.hatXAxisUsed && context.hatYAxisUsed) { + // Suppress this duplicate event if we have a hat + return true; + } + context.inputMap &= ~(ControllerPacket.DOWN_FLAG | ControllerPacket.RIGHT_FLAG); + break; + case KeyEvent.KEYCODE_BUTTON_B: + context.inputMap &= ~ControllerPacket.B_FLAG; + break; + case KeyEvent.KEYCODE_DPAD_CENTER: + case KeyEvent.KEYCODE_BUTTON_A: + context.inputMap &= ~ControllerPacket.A_FLAG; + break; + case KeyEvent.KEYCODE_BUTTON_X: + context.inputMap &= ~ControllerPacket.X_FLAG; + break; + case KeyEvent.KEYCODE_BUTTON_Y: + context.inputMap &= ~ControllerPacket.Y_FLAG; + break; + case KeyEvent.KEYCODE_BUTTON_L1: + context.inputMap &= ~ControllerPacket.LB_FLAG; + context.lastLbUpTime = event.getEventTime(); + break; + case KeyEvent.KEYCODE_BUTTON_R1: + context.inputMap &= ~ControllerPacket.RB_FLAG; + context.lastRbUpTime = event.getEventTime(); + break; + case KeyEvent.KEYCODE_BUTTON_THUMBL: + context.inputMap &= ~ControllerPacket.LS_CLK_FLAG; + break; + case KeyEvent.KEYCODE_BUTTON_THUMBR: + context.inputMap &= ~ControllerPacket.RS_CLK_FLAG; + break; + case KeyEvent.KEYCODE_MEDIA_RECORD: // Xbox Series X Share button + context.inputMap &= ~ControllerPacket.MISC_FLAG; + break; + case KeyEvent.KEYCODE_BUTTON_1: // PS4/PS5 touchpad button (prior to 4.10) + context.inputMap &= ~ControllerPacket.TOUCHPAD_FLAG; + break; + case KeyEvent.KEYCODE_BUTTON_L2: + if (context.leftTriggerAxisUsed) { + // Suppress this digital event if an analog trigger is active + return true; + } + context.leftTrigger = 0; + break; + case KeyEvent.KEYCODE_BUTTON_R2: + if (context.rightTriggerAxisUsed) { + // Suppress this digital event if an analog trigger is active + return true; + } + context.rightTrigger = 0; + break; + case KeyEvent.KEYCODE_UNKNOWN: + // Paddles aren't mapped in any of the Android key layout files, + // so we need to handle the evdev key codes directly. + if (context.hasPaddles) { + switch (event.getScanCode()) { + case 0x2c4: // BTN_TRIGGER_HAPPY5 + context.inputMap &= ~ControllerPacket.PADDLE1_FLAG; + break; + case 0x2c5: // BTN_TRIGGER_HAPPY6 + context.inputMap &= ~ControllerPacket.PADDLE2_FLAG; + break; + case 0x2c6: // BTN_TRIGGER_HAPPY7 + context.inputMap &= ~ControllerPacket.PADDLE3_FLAG; + break; + case 0x2c7: // BTN_TRIGGER_HAPPY8 + context.inputMap &= ~ControllerPacket.PADDLE4_FLAG; + break; + default: + return false; + } + } + else { + return false; + } + break; + default: + return false; + } + + // Check if we're emulating the select button + if ((context.emulatingButtonFlags & ControllerHandler.EMULATING_SELECT) != 0) + { + // If either start or LB is up, select comes up too + if ((context.inputMap & ControllerPacket.PLAY_FLAG) == 0 || + (context.inputMap & ControllerPacket.LB_FLAG) == 0) + { + context.inputMap &= ~ControllerPacket.BACK_FLAG; + + context.emulatingButtonFlags &= ~ControllerHandler.EMULATING_SELECT; + } + } + + // Check if we're emulating the special button + if ((context.emulatingButtonFlags & ControllerHandler.EMULATING_SPECIAL) != 0) + { + // If either start or select and RB is up, the special button comes up too + if ((context.inputMap & ControllerPacket.PLAY_FLAG) == 0 || + ((context.inputMap & ControllerPacket.BACK_FLAG) == 0 && + (context.inputMap & ControllerPacket.RB_FLAG) == 0)) + { + context.inputMap &= ~ControllerPacket.SPECIAL_BUTTON_FLAG; + + context.emulatingButtonFlags &= ~ControllerHandler.EMULATING_SPECIAL; + } + } + + // Check if we're emulating the touchpad button + if ((context.emulatingButtonFlags & ControllerHandler.EMULATING_TOUCHPAD) != 0) + { + // If either select or LB is up, touchpad comes up too + if ((context.inputMap & ControllerPacket.BACK_FLAG) == 0 || + (context.inputMap & ControllerPacket.LB_FLAG) == 0) + { + context.inputMap &= ~ControllerPacket.TOUCHPAD_FLAG; + + context.emulatingButtonFlags &= ~ControllerHandler.EMULATING_TOUCHPAD; + } + } + + sendControllerInputPacket(context); + + if (context.pendingExit && context.inputMap == 0) { + // All buttons from the quit combo are lifted. Finish the activity now. + activityContext.finish(); + } + + return true; + } + + public boolean handleButtonDown(KeyEvent event) { + InputDeviceContext context = getContextForEvent(event); + if (context == null) { + return true; + } + + int keyCode = handleRemapping(context, event); + if (keyCode < 0) { + return (keyCode == REMAP_CONSUME); + } + + if (prefConfig.flipFaceButtons) { + keyCode = handleFlipFaceButtons(keyCode); + } + + switch (keyCode) { + case KeyEvent.KEYCODE_BUTTON_MODE: + context.hasMode = true; + context.inputMap |= ControllerPacket.SPECIAL_BUTTON_FLAG; + break; + case KeyEvent.KEYCODE_BUTTON_START: + case KeyEvent.KEYCODE_MENU: + if (event.getRepeatCount() == 0) { + context.startDownTime = event.getEventTime(); + } + context.inputMap |= ControllerPacket.PLAY_FLAG; + break; + case KeyEvent.KEYCODE_BACK: + case KeyEvent.KEYCODE_BUTTON_SELECT: + context.hasSelect = true; + context.inputMap |= ControllerPacket.BACK_FLAG; + break; + case KeyEvent.KEYCODE_DPAD_LEFT: + if (context.hatXAxisUsed) { + // Suppress this duplicate event if we have a hat + return true; + } + context.inputMap |= ControllerPacket.LEFT_FLAG; + break; + case KeyEvent.KEYCODE_DPAD_RIGHT: + if (context.hatXAxisUsed) { + // Suppress this duplicate event if we have a hat + return true; + } + context.inputMap |= ControllerPacket.RIGHT_FLAG; + break; + case KeyEvent.KEYCODE_DPAD_UP: + if (context.hatYAxisUsed) { + // Suppress this duplicate event if we have a hat + return true; + } + context.inputMap |= ControllerPacket.UP_FLAG; + break; + case KeyEvent.KEYCODE_DPAD_DOWN: + if (context.hatYAxisUsed) { + // Suppress this duplicate event if we have a hat + return true; + } + context.inputMap |= ControllerPacket.DOWN_FLAG; + break; + case KeyEvent.KEYCODE_DPAD_UP_LEFT: + if (context.hatXAxisUsed && context.hatYAxisUsed) { + // Suppress this duplicate event if we have a hat + return true; + } + context.inputMap |= ControllerPacket.UP_FLAG | ControllerPacket.LEFT_FLAG; + break; + case KeyEvent.KEYCODE_DPAD_UP_RIGHT: + if (context.hatXAxisUsed && context.hatYAxisUsed) { + // Suppress this duplicate event if we have a hat + return true; + } + context.inputMap |= ControllerPacket.UP_FLAG | ControllerPacket.RIGHT_FLAG; + break; + case KeyEvent.KEYCODE_DPAD_DOWN_LEFT: + if (context.hatXAxisUsed && context.hatYAxisUsed) { + // Suppress this duplicate event if we have a hat + return true; + } + context.inputMap |= ControllerPacket.DOWN_FLAG | ControllerPacket.LEFT_FLAG; + break; + case KeyEvent.KEYCODE_DPAD_DOWN_RIGHT: + if (context.hatXAxisUsed && context.hatYAxisUsed) { + // Suppress this duplicate event if we have a hat + return true; + } + context.inputMap |= ControllerPacket.DOWN_FLAG | ControllerPacket.RIGHT_FLAG; + break; + case KeyEvent.KEYCODE_BUTTON_B: + context.inputMap |= ControllerPacket.B_FLAG; + break; + case KeyEvent.KEYCODE_DPAD_CENTER: + case KeyEvent.KEYCODE_BUTTON_A: + context.inputMap |= ControllerPacket.A_FLAG; + break; + case KeyEvent.KEYCODE_BUTTON_X: + context.inputMap |= ControllerPacket.X_FLAG; + break; + case KeyEvent.KEYCODE_BUTTON_Y: + context.inputMap |= ControllerPacket.Y_FLAG; + break; + case KeyEvent.KEYCODE_BUTTON_L1: + context.inputMap |= ControllerPacket.LB_FLAG; + break; + case KeyEvent.KEYCODE_BUTTON_R1: + context.inputMap |= ControllerPacket.RB_FLAG; + break; + case KeyEvent.KEYCODE_BUTTON_THUMBL: + context.inputMap |= ControllerPacket.LS_CLK_FLAG; + break; + case KeyEvent.KEYCODE_BUTTON_THUMBR: + context.inputMap |= ControllerPacket.RS_CLK_FLAG; + break; + case KeyEvent.KEYCODE_MEDIA_RECORD: // Xbox Series X Share button + context.inputMap |= ControllerPacket.MISC_FLAG; + break; + case KeyEvent.KEYCODE_BUTTON_1: // PS4/PS5 touchpad button (prior to 4.10) + context.inputMap |= ControllerPacket.TOUCHPAD_FLAG; + break; + case KeyEvent.KEYCODE_BUTTON_L2: + if (context.leftTriggerAxisUsed) { + // Suppress this digital event if an analog trigger is active + return true; + } + context.leftTrigger = (byte)0xFF; + break; + case KeyEvent.KEYCODE_BUTTON_R2: + if (context.rightTriggerAxisUsed) { + // Suppress this digital event if an analog trigger is active + return true; + } + context.rightTrigger = (byte)0xFF; + break; + case KeyEvent.KEYCODE_UNKNOWN: + // Paddles aren't mapped in any of the Android key layout files, + // so we need to handle the evdev key codes directly. + if (context.hasPaddles) { + switch (event.getScanCode()) { + case 0x2c4: // BTN_TRIGGER_HAPPY5 + context.inputMap |= ControllerPacket.PADDLE1_FLAG; + break; + case 0x2c5: // BTN_TRIGGER_HAPPY6 + context.inputMap |= ControllerPacket.PADDLE2_FLAG; + break; + case 0x2c6: // BTN_TRIGGER_HAPPY7 + context.inputMap |= ControllerPacket.PADDLE3_FLAG; + break; + case 0x2c7: // BTN_TRIGGER_HAPPY8 + context.inputMap |= ControllerPacket.PADDLE4_FLAG; + break; + default: + return false; + } + } + else { + return false; + } + break; + default: + return false; + } + + // Start+Back+LB+RB is the quit combo + if (context.inputMap == (ControllerPacket.BACK_FLAG | ControllerPacket.PLAY_FLAG | + ControllerPacket.LB_FLAG | ControllerPacket.RB_FLAG)) { + // Wait for the combo to lift and then finish the activity + context.pendingExit = true; + } + + // Start+LB acts like select for controllers with one button + if (!context.hasSelect) { + if (context.inputMap == (ControllerPacket.PLAY_FLAG | ControllerPacket.LB_FLAG) || + (context.inputMap == ControllerPacket.PLAY_FLAG && + event.getEventTime() - context.lastLbUpTime <= MAXIMUM_BUMPER_UP_DELAY_MS)) + { + context.inputMap &= ~(ControllerPacket.PLAY_FLAG | ControllerPacket.LB_FLAG); + context.inputMap |= ControllerPacket.BACK_FLAG; + + context.emulatingButtonFlags |= ControllerHandler.EMULATING_SELECT; + } + } + else if (context.needsClickpadEmulation) { + // Select+LB acts like the clickpad when we're faking a PS4 controller for motion support + if (context.inputMap == (ControllerPacket.BACK_FLAG | ControllerPacket.LB_FLAG) || + (context.inputMap == ControllerPacket.BACK_FLAG && + event.getEventTime() - context.lastLbUpTime <= MAXIMUM_BUMPER_UP_DELAY_MS)) + { + context.inputMap &= ~(ControllerPacket.BACK_FLAG | ControllerPacket.LB_FLAG); + context.inputMap |= ControllerPacket.TOUCHPAD_FLAG; + + context.emulatingButtonFlags |= ControllerHandler.EMULATING_TOUCHPAD; + } + } + + // If there is a physical select button, we'll use Start+Select as the special button combo + // otherwise we'll use Start+RB. + if (!context.hasMode) { + if (context.hasSelect) { + if (context.inputMap == (ControllerPacket.PLAY_FLAG | ControllerPacket.BACK_FLAG)) { + context.inputMap &= ~(ControllerPacket.PLAY_FLAG | ControllerPacket.BACK_FLAG); + context.inputMap |= ControllerPacket.SPECIAL_BUTTON_FLAG; + + context.emulatingButtonFlags |= ControllerHandler.EMULATING_SPECIAL; + } + } + else { + if (context.inputMap == (ControllerPacket.PLAY_FLAG | ControllerPacket.RB_FLAG) || + (context.inputMap == ControllerPacket.PLAY_FLAG && + event.getEventTime() - context.lastRbUpTime <= MAXIMUM_BUMPER_UP_DELAY_MS)) + { + context.inputMap &= ~(ControllerPacket.PLAY_FLAG | ControllerPacket.RB_FLAG); + context.inputMap |= ControllerPacket.SPECIAL_BUTTON_FLAG; + + context.emulatingButtonFlags |= ControllerHandler.EMULATING_SPECIAL; + } + } + } + + // We don't need to send repeat key down events, but the platform + // sends us events that claim to be repeats but they're from different + // devices, so we just send them all and deal with some duplicates. + sendControllerInputPacket(context); + return true; + } + + public void reportOscState(int buttonFlags, + short leftStickX, short leftStickY, + short rightStickX, short rightStickY, + byte leftTrigger, byte rightTrigger) { + defaultContext.leftStickX = leftStickX; + defaultContext.leftStickY = leftStickY; + + defaultContext.rightStickX = rightStickX; + defaultContext.rightStickY = rightStickY; + + defaultContext.leftTrigger = leftTrigger; + defaultContext.rightTrigger = rightTrigger; + + defaultContext.inputMap = buttonFlags; + + sendControllerInputPacket(defaultContext); + } + + @Override + public void reportControllerState(int controllerId, int buttonFlags, + float leftStickX, float leftStickY, + float rightStickX, float rightStickY, + float leftTrigger, float rightTrigger) { + GenericControllerContext context = usbDeviceContexts.get(controllerId); + if (context == null) { + return; + } + + Vector2d leftStickVector = populateCachedVector(leftStickX, leftStickY); + + handleDeadZone(leftStickVector, context.leftStickDeadzoneRadius); + + context.leftStickX = (short) (leftStickVector.getX() * 0x7FFE); + context.leftStickY = (short) (-leftStickVector.getY() * 0x7FFE); + + Vector2d rightStickVector = populateCachedVector(rightStickX, rightStickY); + + handleDeadZone(rightStickVector, context.rightStickDeadzoneRadius); + + context.rightStickX = (short) (rightStickVector.getX() * 0x7FFE); + context.rightStickY = (short) (-rightStickVector.getY() * 0x7FFE); + + if (leftTrigger <= context.triggerDeadzone) { + leftTrigger = 0; + } + if (rightTrigger <= context.triggerDeadzone) { + rightTrigger = 0; + } + + context.leftTrigger = (byte)(leftTrigger * 0xFF); + context.rightTrigger = (byte)(rightTrigger * 0xFF); + + context.inputMap = buttonFlags; + + sendControllerInputPacket(context); + } + + @Override + public void deviceRemoved(AbstractController controller) { + UsbDeviceContext context = usbDeviceContexts.get(controller.getControllerId()); + if (context != null) { + LimeLog.info("Removed controller: "+controller.getControllerId()); + releaseControllerNumber(context); + context.destroy(); + usbDeviceContexts.remove(controller.getControllerId()); + } + } + + @Override + public void deviceAdded(AbstractController controller) { + if (stopped) { + return; + } + + UsbDeviceContext context = createUsbDeviceContextForDevice(controller); + usbDeviceContexts.put(controller.getControllerId(), context); + } + + class GenericControllerContext implements GameInputDevice{ + public int id; + public boolean external; + + public int vendorId; + public int productId; + + public float leftStickDeadzoneRadius; + public float rightStickDeadzoneRadius; + public float triggerDeadzone; + + public boolean assignedControllerNumber; + public boolean reservedControllerNumber; + public short controllerNumber; + + public int inputMap = 0; + public byte leftTrigger = 0x00; + public byte rightTrigger = 0x00; + public short rightStickX = 0x0000; + public short rightStickY = 0x0000; + public short leftStickX = 0x0000; + public short leftStickY = 0x0000; + + public boolean mouseEmulationActive; + public int mouseEmulationLastInputMap; + public final int mouseEmulationReportPeriod = 50; + + public final Runnable mouseEmulationRunnable = new Runnable() { + @Override + public void run() { + if (!mouseEmulationActive) { + return; + } + + // Send mouse events from analog sticks + if (prefConfig.analogStickForScrolling == PreferenceConfiguration.AnalogStickForScrolling.RIGHT) { + sendEmulatedMouseMove(leftStickX, leftStickY); + sendEmulatedMouseScroll(rightStickX, rightStickY); + } + else if (prefConfig.analogStickForScrolling == PreferenceConfiguration.AnalogStickForScrolling.LEFT) { + sendEmulatedMouseMove(rightStickX, rightStickY); + sendEmulatedMouseScroll(leftStickX, leftStickY); + } + else { + sendEmulatedMouseMove(leftStickX, leftStickY); + sendEmulatedMouseMove(rightStickX, rightStickY); + } + + // Requeue the callback + mainThreadHandler.postDelayed(this, mouseEmulationReportPeriod); + } + }; + + @Override + public List getGameMenuOptions() { + List options = new ArrayList<>(); + options.add(new GameMenu.MenuOption(activityContext.getString(mouseEmulationActive ? + R.string.game_menu_toggle_mouse_off : R.string.game_menu_toggle_mouse_on), + true, () -> toggleMouseEmulation())); + + return options; + } + + public void toggleMouseEmulation() { + mainThreadHandler.removeCallbacks(mouseEmulationRunnable); + mouseEmulationActive = !mouseEmulationActive; + Toast.makeText(activityContext, "Mouse emulation is: " + (mouseEmulationActive ? "ON" : "OFF"), Toast.LENGTH_SHORT).show(); + + if (mouseEmulationActive) { + mainThreadHandler.postDelayed(mouseEmulationRunnable, mouseEmulationReportPeriod); + } + } + + public void destroy() { + mouseEmulationActive = false; + mainThreadHandler.removeCallbacks(mouseEmulationRunnable); + } + + public void sendControllerArrival() {} + + } + + class InputDeviceContext extends GenericControllerContext { + public String name; + public VibratorManager vibratorManager; + public Vibrator vibrator; + public boolean quadVibrators; + public short lowFreqMotor, highFreqMotor; + public short leftTriggerMotor, rightTriggerMotor; + + public SensorManager sensorManager; + public SensorEventListener gyroListener; + public short gyroReportRateHz; + public SensorEventListener accelListener; + public short accelReportRateHz; + + public InputDevice inputDevice; + + public boolean hasRgbLed; + public LightsManager.LightsSession lightsSession; + + // These are BatteryState values, not Moonlight values + public int lastReportedBatteryStatus; + public float lastReportedBatteryCapacity; + + public int leftStickXAxis = -1; + public int leftStickYAxis = -1; + + public int rightStickXAxis = -1; + public int rightStickYAxis = -1; + + public int leftTriggerAxis = -1; + public int rightTriggerAxis = -1; + public boolean triggersIdleNegative; + public boolean leftTriggerAxisUsed, rightTriggerAxisUsed; + + public int hatXAxis = -1; + public int hatYAxis = -1; + public boolean hatXAxisUsed, hatYAxisUsed; + + InputDevice.MotionRange touchpadXRange; + InputDevice.MotionRange touchpadYRange; + InputDevice.MotionRange touchpadPressureRange; + + public boolean isNonStandardDualShock4; + public boolean usesLinuxGamepadStandardFaceButtons; + public boolean isNonStandardXboxBtController; + public boolean isServal; + public boolean backIsStart; + public boolean modeIsSelect; + public boolean searchIsMode; + public boolean ignoreBack; + public boolean hasJoystickAxes; + public boolean pendingExit; + public boolean isDualShockStandaloneTouchpad; + + public int emulatingButtonFlags = 0; + public boolean hasSelect; + public boolean hasMode; + public boolean hasPaddles; + public boolean hasShare; + public boolean needsClickpadEmulation; + + // Used for OUYA bumper state tracking since they force all buttons + // up when the OUYA button goes down. We watch the last time we get + // a bumper up and compare that to our maximum delay when we receive + // a Start button press to see if we should activate one of our + // emulated button combos. + public long lastLbUpTime = 0; + public long lastRbUpTime = 0; + + public long startDownTime = 0; + + public final Runnable batteryStateUpdateRunnable = new Runnable() { + @Override + public void run() { + sendControllerBatteryPacket(InputDeviceContext.this); + + // Requeue the callback + backgroundThreadHandler.postDelayed(this, BATTERY_RECHECK_INTERVAL_MS); + } + }; + + public final Runnable enableSensorRunnable = new Runnable() { + @Override + public void run() { + // Turn back on any sensors that should be reporting but are currently unregistered + if (accelReportRateHz != 0 && accelListener == null) { + handleSetMotionEventState(controllerNumber, MoonBridge.LI_MOTION_TYPE_ACCEL, accelReportRateHz); + } + if (gyroReportRateHz != 0 && gyroListener == null) { + handleSetMotionEventState(controllerNumber, MoonBridge.LI_MOTION_TYPE_GYRO, gyroReportRateHz); + } + } + }; + + @Override + public void destroy() { + super.destroy(); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && vibratorManager != null) { + vibratorManager.cancel(); + } + else if (vibrator != null) { + vibrator.cancel(); + } + + backgroundThreadHandler.removeCallbacks(enableSensorRunnable); + + if (gyroListener != null) { + sensorManager.unregisterListener(gyroListener); + } + if (accelListener != null) { + sensorManager.unregisterListener(accelListener); + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (lightsSession != null) { + lightsSession.close(); + } + } + + backgroundThreadHandler.removeCallbacks(batteryStateUpdateRunnable); + } + + @Override + public void sendControllerArrival() { + byte type; + switch (inputDevice.getVendorId()) { + case 0x045e: // Microsoft + type = MoonBridge.LI_CTYPE_XBOX; + break; + case 0x054c: // Sony + type = MoonBridge.LI_CTYPE_PS; + break; + case 0x057e: // Nintendo + type = MoonBridge.LI_CTYPE_NINTENDO; + break; + default: + // Consult SDL's controller type list to see if it knows + type = MoonBridge.guessControllerType(inputDevice.getVendorId(), inputDevice.getProductId()); + break; + } + + int supportedButtonFlags = 0; + for (Map.Entry entry : ANDROID_TO_LI_BUTTON_MAP.entrySet()) { + if (inputDevice.hasKeys(entry.getKey())[0]) { + supportedButtonFlags |= entry.getValue(); + } + } + + // Add non-standard button flags that may not be mapped in the Android kl file + if (hasPaddles) { + supportedButtonFlags |= + ControllerPacket.PADDLE1_FLAG | + ControllerPacket.PADDLE2_FLAG | + ControllerPacket.PADDLE3_FLAG | + ControllerPacket.PADDLE4_FLAG; + } + if (hasShare) { + supportedButtonFlags |= ControllerPacket.MISC_FLAG; + } + + if (getMotionRangeForJoystickAxis(inputDevice, MotionEvent.AXIS_HAT_X) != null) { + supportedButtonFlags |= ControllerPacket.LEFT_FLAG | ControllerPacket.RIGHT_FLAG; + } + if (getMotionRangeForJoystickAxis(inputDevice, MotionEvent.AXIS_HAT_Y) != null) { + supportedButtonFlags |= ControllerPacket.UP_FLAG | ControllerPacket.DOWN_FLAG; + } + + short capabilities = 0; + + // Most of the advanced InputDevice capabilities came in Android S + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (quadVibrators) { + capabilities |= MoonBridge.LI_CCAP_RUMBLE | MoonBridge.LI_CCAP_TRIGGER_RUMBLE; + } + else if (vibratorManager != null || vibrator != null) { + capabilities |= MoonBridge.LI_CCAP_RUMBLE; + } + + // Calling InputDevice.getBatteryState() to see if a battery is present + // performs a Binder transaction that can cause ANRs on some devices. + // To avoid this, we will just claim we can report battery state for all + // external gamepad devices on Android S. If it turns out that no battery + // is actually present, we'll just report unknown battery state to the host. + if (external) { + capabilities |= MoonBridge.LI_CCAP_BATTERY_STATE; + } + + // Light.hasRgbControl() was totally broken prior to Android 14. + // It always returned true because LIGHT_CAPABILITY_RGB was defined as 0, + // so we will just guess RGB is supported if it's a PlayStation controller. + if (hasRgbLed && (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE || type == MoonBridge.LI_CTYPE_PS)) { + capabilities |= MoonBridge.LI_CCAP_RGB_LED; + } + } + + // Report analog triggers if we have at least one trigger axis + if (leftTriggerAxis != -1 || rightTriggerAxis != -1) { + capabilities |= MoonBridge.LI_CCAP_ANALOG_TRIGGERS; + } + + // Report sensors if the input device has them or we're using built-in sensors for a built-in controller + if (sensorManager != null && sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null) { + capabilities |= MoonBridge.LI_CCAP_ACCEL; + } + if (sensorManager != null && sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE) != null) { + capabilities |= MoonBridge.LI_CCAP_GYRO; + } + + byte reportedType; + if (type != MoonBridge.LI_CTYPE_PS && sensorManager != null) { + // Override the detected controller type if we're emulating motion sensors on an Xbox controller + Toast.makeText(activityContext, activityContext.getResources().getText(R.string.toast_controller_type_changed), Toast.LENGTH_LONG).show(); + reportedType = MoonBridge.LI_CTYPE_UNKNOWN; + + // Remember that we should enable the clickpad emulation combo (Select+LB) for this device + needsClickpadEmulation = true; + } + else { + // Report the true type to the host PC if we're not emulating motion sensors + reportedType = type; + } + + // We can perform basic rumble with any vibrator + if (vibrator != null) { + capabilities |= MoonBridge.LI_CCAP_RUMBLE; + } + + // Shield controllers use special APIs for rumble and battery state + if (sceManager.isRecognizedDevice(inputDevice)) { + capabilities |= MoonBridge.LI_CCAP_RUMBLE | MoonBridge.LI_CCAP_BATTERY_STATE; + } + + if ((inputDevice.getSources() & InputDevice.SOURCE_TOUCHPAD) == InputDevice.SOURCE_TOUCHPAD) { + capabilities |= MoonBridge.LI_CCAP_TOUCHPAD; + + // Use the platform API or internal heuristics to determine if this has a clickpad + if (hasButtonUnderTouchpad(inputDevice, type)) { + supportedButtonFlags |= ControllerPacket.TOUCHPAD_FLAG; + } + } + + conn.sendControllerArrivalEvent((byte)controllerNumber, getActiveControllerMask(), + reportedType, supportedButtonFlags, capabilities); + + // After reporting arrival to the host, send initial battery state and begin monitoring + backgroundThreadHandler.post(batteryStateUpdateRunnable); + } + + public void migrateContext(InputDeviceContext oldContext) { + // Take ownership of the sensor and light sessions + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + this.lightsSession = oldContext.lightsSession; + oldContext.lightsSession = null; + } + this.gyroReportRateHz = oldContext.gyroReportRateHz; + this.accelReportRateHz = oldContext.accelReportRateHz; + + // Don't release the controller number, because we will carry it over if it is present. + // We also want to make sure the change is invisible to the host PC to avoid an add/remove + // cycle for the gamepad which may break some games. + oldContext.destroy(); + + // Copy over existing controller number state + this.assignedControllerNumber = oldContext.assignedControllerNumber; + this.reservedControllerNumber = oldContext.reservedControllerNumber; + this.controllerNumber = oldContext.controllerNumber; + + // We may have set this device to use the built-in sensor manager. If so, do that again. + if (oldContext.sensorManager == deviceSensorManager) { + this.sensorManager = deviceSensorManager; + } + + // Copy state initialized in reportControllerArrival() + this.needsClickpadEmulation = oldContext.needsClickpadEmulation; + + // Re-enable sensors on the new context + enableSensors(); + + // Refresh battery state and start the battery state polling again + backgroundThreadHandler.post(batteryStateUpdateRunnable); + } + + public void disableSensors() { + // Stop any pending enablement + backgroundThreadHandler.removeCallbacks(enableSensorRunnable); + + // Unregister all sensor listeners + if (gyroListener != null) { + sensorManager.unregisterListener(gyroListener); + gyroListener = null; + + // Send a gyro event to ensure the virtual controller is stationary + conn.sendControllerMotionEvent((byte) controllerNumber, MoonBridge.LI_MOTION_TYPE_GYRO, 0.f, 0.f, 0.f); + } + if (accelListener != null) { + sensorManager.unregisterListener(accelListener); + accelListener = null; + + // We leave the acceleration as-is to preserve the attitude of the controller + } + } + + public void enableSensors() { + // We allow 1 second for the input device to settle before re-enabling sensors. + // Pointer capture can cause the input device to change, which can cause + // InputDeviceSensorManager to crash due to missing null checks on the InputDevice. + backgroundThreadHandler.postDelayed(enableSensorRunnable, 1000); + } + } + + class UsbDeviceContext extends GenericControllerContext { + public AbstractController device; + + @Override + public void destroy() { + super.destroy(); + + // Nothing for now + } + + @Override + public void sendControllerArrival() { + conn.sendControllerArrivalEvent((byte)controllerNumber, getActiveControllerMask(), + device.getType(), device.getSupportedButtonFlags(), device.getCapabilities()); + } + } +} diff --git a/app/src/main/java/com/limelight/binding/input/GameInputDevice.java b/app/src/main/java/com/limelight/binding/input/GameInputDevice.java new file mode 100755 index 0000000000..c1fa51bb50 --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/GameInputDevice.java @@ -0,0 +1,19 @@ +package com.limelight.binding.input; + +import com.limelight.GameMenu; + +import java.util.List; + +/** + * Description + * Date: 2024-01-16 + * Time: 15:26 + * User: Genng(genng1991@gmail.com) + */ +public interface GameInputDevice { + + /** + * @return list of device specific game menu options, e.g. configure a controller's mouse mode + */ + List getGameMenuOptions(); +} \ No newline at end of file diff --git a/app/src/main/java/com/limelight/binding/input/KeyboardTranslator.java b/app/src/main/java/com/limelight/binding/input/KeyboardTranslator.java old mode 100644 new mode 100755 index 5c5b4cc847..88bdbf62e3 --- a/app/src/main/java/com/limelight/binding/input/KeyboardTranslator.java +++ b/app/src/main/java/com/limelight/binding/input/KeyboardTranslator.java @@ -1,386 +1,419 @@ -package com.limelight.binding.input; - -import android.annotation.TargetApi; -import android.hardware.input.InputManager; -import android.os.Build; -import android.util.SparseArray; -import android.view.InputDevice; -import android.view.KeyEvent; - -import java.util.Arrays; - -/** - * Class to translate a Android key code into the codes GFE is expecting - * @author Diego Waxemberg - * @author Cameron Gutman - */ -public class KeyboardTranslator implements InputManager.InputDeviceListener { - - /** - * GFE's prefix for every key code - */ - private static final short KEY_PREFIX = (short) 0x80; - - public static final int VK_0 = 48; - public static final int VK_9 = 57; - public static final int VK_A = 65; - public static final int VK_Z = 90; - public static final int VK_NUMPAD0 = 96; - public static final int VK_BACK_SLASH = 92; - public static final int VK_CAPS_LOCK = 20; - public static final int VK_CLEAR = 12; - public static final int VK_COMMA = 44; - public static final int VK_BACK_SPACE = 8; - public static final int VK_EQUALS = 61; - public static final int VK_ESCAPE = 27; - public static final int VK_F1 = 112; - public static final int VK_END = 35; - public static final int VK_HOME = 36; - public static final int VK_NUM_LOCK = 144; - public static final int VK_PAGE_UP = 33; - public static final int VK_PAGE_DOWN = 34; - public static final int VK_PLUS = 521; - public static final int VK_CLOSE_BRACKET = 93; - public static final int VK_SCROLL_LOCK = 145; - public static final int VK_SEMICOLON = 59; - public static final int VK_SLASH = 47; - public static final int VK_SPACE = 32; - public static final int VK_PRINTSCREEN = 154; - public static final int VK_TAB = 9; - public static final int VK_LEFT = 37; - public static final int VK_RIGHT = 39; - public static final int VK_UP = 38; - public static final int VK_DOWN = 40; - public static final int VK_BACK_QUOTE = 192; - public static final int VK_QUOTE = 222; - public static final int VK_PAUSE = 19; - - private static class KeyboardMapping { - private final InputDevice device; - private final int[] deviceKeyCodeToQwertyKeyCode; - - @TargetApi(33) - public KeyboardMapping(InputDevice device) { - int maxKeyCode = KeyEvent.getMaxKeyCode(); - - this.device = device; - this.deviceKeyCodeToQwertyKeyCode = new int[maxKeyCode + 1]; - - // Any unmatched keycodes are treated as unknown - Arrays.fill(deviceKeyCodeToQwertyKeyCode, KeyEvent.KEYCODE_UNKNOWN); - - for (int i = 0; i <= maxKeyCode; i++) { - int deviceKeyCode = device.getKeyCodeForKeyLocation(i); - if (deviceKeyCode != KeyEvent.KEYCODE_UNKNOWN) { - deviceKeyCodeToQwertyKeyCode[deviceKeyCode] = i; - } - } - } - - @TargetApi(33) - public int getDeviceKeyCodeForQwertyKeyCode(int qwertyKeyCode) { - return device.getKeyCodeForKeyLocation(qwertyKeyCode); - } - - public int getQwertyKeyCodeForDeviceKeyCode(int deviceKeyCode) { - if (deviceKeyCode > KeyEvent.getMaxKeyCode()) { - return KeyEvent.KEYCODE_UNKNOWN; - } - - return deviceKeyCodeToQwertyKeyCode[deviceKeyCode]; - } - } - - private final SparseArray keyboardMappings = new SparseArray<>(); - - public KeyboardTranslator() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - for (int deviceId : InputDevice.getDeviceIds()) { - InputDevice device = InputDevice.getDevice(deviceId); - if (device != null && device.getKeyboardType() == InputDevice.KEYBOARD_TYPE_ALPHABETIC) { - keyboardMappings.set(deviceId, new KeyboardMapping(device)); - } - } - } - } - - public boolean hasNormalizedMapping(int keycode, int deviceId) { - if (deviceId >= 0) { - KeyboardMapping mapping = keyboardMappings.get(deviceId); - if (mapping != null) { - // Try to map this device-specific keycode onto a QWERTY layout. - // GFE assumes incoming keycodes are from a QWERTY keyboard. - int qwertyKeyCode = mapping.getQwertyKeyCodeForDeviceKeyCode(keycode); - if (qwertyKeyCode != KeyEvent.KEYCODE_UNKNOWN) { - return true; - } - } - } - - return false; - } - - /** - * Translates the given keycode and returns the GFE keycode - * @param keycode the code to be translated - * @param deviceId InputDevice.getId() or -1 if unknown - * @return a GFE keycode for the given keycode - */ - public short translate(int keycode, int deviceId) { - int translated; - - // If a device ID was provided, look up the keyboard mapping - if (deviceId >= 0) { - KeyboardMapping mapping = keyboardMappings.get(deviceId); - if (mapping != null) { - // Try to map this device-specific keycode onto a QWERTY layout. - // GFE assumes incoming keycodes are from a QWERTY keyboard. - int qwertyKeyCode = mapping.getQwertyKeyCodeForDeviceKeyCode(keycode); - if (qwertyKeyCode != KeyEvent.KEYCODE_UNKNOWN) { - keycode = qwertyKeyCode; - } - } - } - - // This is a poor man's mapping between Android key codes - // and Windows VK_* codes. For all defined VK_ codes, see: - // https://msdn.microsoft.com/en-us/library/windows/desktop/dd375731(v=vs.85).aspx - if (keycode >= KeyEvent.KEYCODE_0 && - keycode <= KeyEvent.KEYCODE_9) { - translated = (keycode - KeyEvent.KEYCODE_0) + VK_0; - } - else if (keycode >= KeyEvent.KEYCODE_A && - keycode <= KeyEvent.KEYCODE_Z) { - translated = (keycode - KeyEvent.KEYCODE_A) + VK_A; - } - else if (keycode >= KeyEvent.KEYCODE_NUMPAD_0 && - keycode <= KeyEvent.KEYCODE_NUMPAD_9) { - translated = (keycode - KeyEvent.KEYCODE_NUMPAD_0) + VK_NUMPAD0; - } - else if (keycode >= KeyEvent.KEYCODE_F1 && - keycode <= KeyEvent.KEYCODE_F12) { - translated = (keycode - KeyEvent.KEYCODE_F1) + VK_F1; - } - else { - switch (keycode) { - case KeyEvent.KEYCODE_ALT_LEFT: - translated = 0xA4; - break; - - case KeyEvent.KEYCODE_ALT_RIGHT: - translated = 0xA5; - break; - - case KeyEvent.KEYCODE_BACKSLASH: - translated = 0xdc; - break; - - case KeyEvent.KEYCODE_CAPS_LOCK: - translated = VK_CAPS_LOCK; - break; - - case KeyEvent.KEYCODE_CLEAR: - translated = VK_CLEAR; - break; - - case KeyEvent.KEYCODE_COMMA: - translated = 0xbc; - break; - - case KeyEvent.KEYCODE_CTRL_LEFT: - translated = 0xA2; - break; - - case KeyEvent.KEYCODE_CTRL_RIGHT: - translated = 0xA3; - break; - - case KeyEvent.KEYCODE_DEL: - translated = VK_BACK_SPACE; - break; - - case KeyEvent.KEYCODE_ENTER: - translated = 0x0d; - break; - - case KeyEvent.KEYCODE_PLUS: - case KeyEvent.KEYCODE_EQUALS: - translated = 0xbb; - break; - - case KeyEvent.KEYCODE_ESCAPE: - translated = VK_ESCAPE; - break; - - case KeyEvent.KEYCODE_FORWARD_DEL: - translated = 0x2e; - break; - - case KeyEvent.KEYCODE_INSERT: - translated = 0x2d; - break; - - case KeyEvent.KEYCODE_LEFT_BRACKET: - translated = 0xdb; - break; - - case KeyEvent.KEYCODE_META_LEFT: - translated = 0x5b; - break; - - case KeyEvent.KEYCODE_META_RIGHT: - translated = 0x5c; - break; - - case KeyEvent.KEYCODE_MENU: - translated = 0x5d; - break; - - case KeyEvent.KEYCODE_MINUS: - translated = 0xbd; - break; - - case KeyEvent.KEYCODE_MOVE_END: - translated = VK_END; - break; - - case KeyEvent.KEYCODE_MOVE_HOME: - translated = VK_HOME; - break; - - case KeyEvent.KEYCODE_NUM_LOCK: - translated = VK_NUM_LOCK; - break; - - case KeyEvent.KEYCODE_PAGE_DOWN: - translated = VK_PAGE_DOWN; - break; - - case KeyEvent.KEYCODE_PAGE_UP: - translated = VK_PAGE_UP; - break; - - case KeyEvent.KEYCODE_PERIOD: - translated = 0xbe; - break; - - case KeyEvent.KEYCODE_RIGHT_BRACKET: - translated = 0xdd; - break; - - case KeyEvent.KEYCODE_SCROLL_LOCK: - translated = VK_SCROLL_LOCK; - break; - - case KeyEvent.KEYCODE_SEMICOLON: - translated = 0xba; - break; - - case KeyEvent.KEYCODE_SHIFT_LEFT: - translated = 0xA0; - break; - - case KeyEvent.KEYCODE_SHIFT_RIGHT: - translated = 0xA1; - break; - - case KeyEvent.KEYCODE_SLASH: - translated = 0xbf; - break; - - case KeyEvent.KEYCODE_SPACE: - translated = VK_SPACE; - break; - - case KeyEvent.KEYCODE_SYSRQ: - // Android defines this as SysRq/PrntScrn - translated = VK_PRINTSCREEN; - break; - - case KeyEvent.KEYCODE_TAB: - translated = VK_TAB; - break; - - case KeyEvent.KEYCODE_DPAD_LEFT: - translated = VK_LEFT; - break; - - case KeyEvent.KEYCODE_DPAD_RIGHT: - translated = VK_RIGHT; - break; - - case KeyEvent.KEYCODE_DPAD_UP: - translated = VK_UP; - break; - - case KeyEvent.KEYCODE_DPAD_DOWN: - translated = VK_DOWN; - break; - - case KeyEvent.KEYCODE_GRAVE: - translated = VK_BACK_QUOTE; - break; - - case KeyEvent.KEYCODE_APOSTROPHE: - translated = 0xde; - break; - - case KeyEvent.KEYCODE_BREAK: - translated = VK_PAUSE; - break; - - case KeyEvent.KEYCODE_NUMPAD_DIVIDE: - translated = 0x6F; - break; - - case KeyEvent.KEYCODE_NUMPAD_MULTIPLY: - translated = 0x6A; - break; - - case KeyEvent.KEYCODE_NUMPAD_SUBTRACT: - translated = 0x6D; - break; - - case KeyEvent.KEYCODE_NUMPAD_ADD: - translated = 0x6B; - break; - - case KeyEvent.KEYCODE_NUMPAD_DOT: - translated = 0x6E; - break; - - default: - return 0; - } - } - - return (short) ((KEY_PREFIX << 8) | translated); - } - - @Override - public void onInputDeviceAdded(int index) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - InputDevice device = InputDevice.getDevice(index); - if (device != null && device.getKeyboardType() == InputDevice.KEYBOARD_TYPE_ALPHABETIC) { - keyboardMappings.put(index, new KeyboardMapping(device)); - } - } - } - - @Override - public void onInputDeviceRemoved(int index) { - keyboardMappings.remove(index); - } - - @Override - public void onInputDeviceChanged(int index) { - keyboardMappings.remove(index); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - InputDevice device = InputDevice.getDevice(index); - if (device != null && device.getKeyboardType() == InputDevice.KEYBOARD_TYPE_ALPHABETIC) { - keyboardMappings.set(index, new KeyboardMapping(device)); - } - } - } -} +package com.limelight.binding.input; + +import android.annotation.TargetApi; +import android.hardware.input.InputManager; +import android.os.Build; +import android.util.SparseArray; +import android.view.InputDevice; +import android.view.KeyEvent; + +import java.util.Arrays; + +/** + * Class to translate a Android key code into the codes GFE is expecting + * @author Diego Waxemberg + * @author Cameron Gutman + */ +public class KeyboardTranslator implements InputManager.InputDeviceListener { + + /** + * GFE's prefix for every key code + */ + private static final short KEY_PREFIX = (short) 0x80; + + public static final int VK_0 = 48; + public static final int VK_9 = 57; + public static final int VK_A = 65; + public static final int VK_Z = 90; + public static final int VK_NUMPAD0 = 96; + public static final int VK_BACK_SLASH = 92; + public static final int VK_CAPS_LOCK = 20; + public static final int VK_CLEAR = 12; + public static final int VK_COMMA = 44; + public static final int VK_BACK_SPACE = 8; + public static final int VK_EQUALS = 61; + public static final int VK_ESCAPE = 27; + public static final int VK_F1 = 112; + public static final int VK_F12 = 123; + + public static final int VK_END = 35; + public static final int VK_HOME = 36; + public static final int VK_NUM_LOCK = 144; + public static final int VK_PAGE_UP = 33; + public static final int VK_PAGE_DOWN = 34; + public static final int VK_PLUS = 521; + public static final int VK_CLOSE_BRACKET = 93; + public static final int VK_SCROLL_LOCK = 145; + public static final int VK_SEMICOLON = 59; + public static final int VK_SLASH = 47; + public static final int VK_SPACE = 32; + public static final int VK_PRINTSCREEN = 154; + public static final int VK_TAB = 9; + public static final int VK_LEFT = 37; + public static final int VK_RIGHT = 39; + public static final int VK_UP = 38; + public static final int VK_DOWN = 40; + public static final int VK_BACK_QUOTE = 192; + public static final int VK_QUOTE = 222; + public static final int VK_PAUSE = 19; + + public static final int VK_B = 66; + + public static final int VK_C = 67; + public static final int VK_D = 68; + public static final int VK_G = 71; + public static final int VK_V = 86; + public static final int VK_Q = 81; + + public static final int VK_S = 83; + + public static final int VK_U = 85; + + public static final int VK_X = 88; + public static final int VK_R = 82; + + public static final int VK_I = 73; + + public static final int VK_F11 = 122; + public static final int VK_LWIN = 91; + public static final int VK_LSHIFT = 160; + public static final int VK_LCONTROL = 162; + + //Left ALT key + public static final int VK_LMENU = 164; + //ENTER key + public static final int VK_RETURN = 13; + + public static final int VK_F4 = 115; + + + + private static class KeyboardMapping { + private final InputDevice device; + private final int[] deviceKeyCodeToQwertyKeyCode; + + @TargetApi(33) + public KeyboardMapping(InputDevice device) { + int maxKeyCode = KeyEvent.getMaxKeyCode(); + + this.device = device; + this.deviceKeyCodeToQwertyKeyCode = new int[maxKeyCode + 1]; + + // Any unmatched keycodes are treated as unknown + Arrays.fill(deviceKeyCodeToQwertyKeyCode, KeyEvent.KEYCODE_UNKNOWN); + + for (int i = 0; i <= maxKeyCode; i++) { + int deviceKeyCode = device.getKeyCodeForKeyLocation(i); + if (deviceKeyCode != KeyEvent.KEYCODE_UNKNOWN) { + deviceKeyCodeToQwertyKeyCode[deviceKeyCode] = i; + } + } + } + + @TargetApi(33) + public int getDeviceKeyCodeForQwertyKeyCode(int qwertyKeyCode) { + return device.getKeyCodeForKeyLocation(qwertyKeyCode); + } + + public int getQwertyKeyCodeForDeviceKeyCode(int deviceKeyCode) { + if (deviceKeyCode > KeyEvent.getMaxKeyCode()) { + return KeyEvent.KEYCODE_UNKNOWN; + } + + return deviceKeyCodeToQwertyKeyCode[deviceKeyCode]; + } + } + + private final SparseArray keyboardMappings = new SparseArray<>(); + + public KeyboardTranslator() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + for (int deviceId : InputDevice.getDeviceIds()) { + InputDevice device = InputDevice.getDevice(deviceId); + if (device != null && device.getKeyboardType() == InputDevice.KEYBOARD_TYPE_ALPHABETIC) { + keyboardMappings.set(deviceId, new KeyboardMapping(device)); + } + } + } + } + + public boolean hasNormalizedMapping(int keycode, int deviceId) { + if (deviceId >= 0) { + KeyboardMapping mapping = keyboardMappings.get(deviceId); + if (mapping != null) { + // Try to map this device-specific keycode onto a QWERTY layout. + // GFE assumes incoming keycodes are from a QWERTY keyboard. + int qwertyKeyCode = mapping.getQwertyKeyCodeForDeviceKeyCode(keycode); + if (qwertyKeyCode != KeyEvent.KEYCODE_UNKNOWN) { + return true; + } + } + } + + return false; + } + + /** + * Translates the given keycode and returns the GFE keycode + * @param keycode the code to be translated + * @param deviceId InputDevice.getId() or -1 if unknown + * @return a GFE keycode for the given keycode + */ + public short translate(int keycode, int deviceId) { + int translated; + + // If a device ID was provided, look up the keyboard mapping + if (deviceId >= 0) { + KeyboardMapping mapping = keyboardMappings.get(deviceId); + if (mapping != null) { + // Try to map this device-specific keycode onto a QWERTY layout. + // GFE assumes incoming keycodes are from a QWERTY keyboard. + int qwertyKeyCode = mapping.getQwertyKeyCodeForDeviceKeyCode(keycode); + if (qwertyKeyCode != KeyEvent.KEYCODE_UNKNOWN) { + keycode = qwertyKeyCode; + } + } + } + + // This is a poor man's mapping between Android key codes + // and Windows VK_* codes. For all defined VK_ codes, see: + // https://msdn.microsoft.com/en-us/library/windows/desktop/dd375731(v=vs.85).aspx + if (keycode >= KeyEvent.KEYCODE_0 && + keycode <= KeyEvent.KEYCODE_9) { + translated = (keycode - KeyEvent.KEYCODE_0) + VK_0; + } + else if (keycode >= KeyEvent.KEYCODE_A && + keycode <= KeyEvent.KEYCODE_Z) { + translated = (keycode - KeyEvent.KEYCODE_A) + VK_A; + } + else if (keycode >= KeyEvent.KEYCODE_NUMPAD_0 && + keycode <= KeyEvent.KEYCODE_NUMPAD_9) { + translated = (keycode - KeyEvent.KEYCODE_NUMPAD_0) + VK_NUMPAD0; + } + else if (keycode >= KeyEvent.KEYCODE_F1 && + keycode <= KeyEvent.KEYCODE_F12) { + translated = (keycode - KeyEvent.KEYCODE_F1) + VK_F1; + } + else { + switch (keycode) { + case KeyEvent.KEYCODE_ALT_LEFT: + translated = 0xA4; + break; + + case KeyEvent.KEYCODE_ALT_RIGHT: + translated = 0xA5; + break; + + case KeyEvent.KEYCODE_BACKSLASH: + translated = 0xdc; + break; + + case KeyEvent.KEYCODE_CAPS_LOCK: + translated = VK_CAPS_LOCK; + break; + + case KeyEvent.KEYCODE_CLEAR: + translated = VK_CLEAR; + break; + + case KeyEvent.KEYCODE_COMMA: + translated = 0xbc; + break; + + case KeyEvent.KEYCODE_CTRL_LEFT: + translated = 0xA2; + break; + + case KeyEvent.KEYCODE_CTRL_RIGHT: + translated = 0xA3; + break; + + case KeyEvent.KEYCODE_DEL: + translated = VK_BACK_SPACE; + break; + + case KeyEvent.KEYCODE_ENTER: + translated = 0x0d; + break; + + case KeyEvent.KEYCODE_PLUS: + case KeyEvent.KEYCODE_EQUALS: + translated = 0xbb; + break; + + case KeyEvent.KEYCODE_ESCAPE: + translated = VK_ESCAPE; + break; + + case KeyEvent.KEYCODE_FORWARD_DEL: + translated = 0x2e; + break; + + case KeyEvent.KEYCODE_INSERT: + translated = 0x2d; + break; + + case KeyEvent.KEYCODE_LEFT_BRACKET: + translated = 0xdb; + break; + + case KeyEvent.KEYCODE_META_LEFT: + translated = 0x5b; + break; + + case KeyEvent.KEYCODE_META_RIGHT: + translated = 0x5c; + break; + + case KeyEvent.KEYCODE_MENU: + translated = 0x5d; + break; + + case KeyEvent.KEYCODE_MINUS: + translated = 0xbd; + break; + + case KeyEvent.KEYCODE_MOVE_END: + translated = VK_END; + break; + + case KeyEvent.KEYCODE_MOVE_HOME: + translated = VK_HOME; + break; + + case KeyEvent.KEYCODE_NUM_LOCK: + translated = VK_NUM_LOCK; + break; + + case KeyEvent.KEYCODE_PAGE_DOWN: + translated = VK_PAGE_DOWN; + break; + + case KeyEvent.KEYCODE_PAGE_UP: + translated = VK_PAGE_UP; + break; + + case KeyEvent.KEYCODE_PERIOD: + translated = 0xbe; + break; + + case KeyEvent.KEYCODE_RIGHT_BRACKET: + translated = 0xdd; + break; + + case KeyEvent.KEYCODE_SCROLL_LOCK: + translated = VK_SCROLL_LOCK; + break; + + case KeyEvent.KEYCODE_SEMICOLON: + translated = 0xba; + break; + + case KeyEvent.KEYCODE_SHIFT_LEFT: + translated = 0xA0; + break; + + case KeyEvent.KEYCODE_SHIFT_RIGHT: + translated = 0xA1; + break; + + case KeyEvent.KEYCODE_SLASH: + translated = 0xbf; + break; + + case KeyEvent.KEYCODE_SPACE: + translated = VK_SPACE; + break; + + case KeyEvent.KEYCODE_SYSRQ: + // Android defines this as SysRq/PrntScrn + translated = VK_PRINTSCREEN; + break; + + case KeyEvent.KEYCODE_TAB: + translated = VK_TAB; + break; + + case KeyEvent.KEYCODE_DPAD_LEFT: + translated = VK_LEFT; + break; + + case KeyEvent.KEYCODE_DPAD_RIGHT: + translated = VK_RIGHT; + break; + + case KeyEvent.KEYCODE_DPAD_UP: + translated = VK_UP; + break; + + case KeyEvent.KEYCODE_DPAD_DOWN: + translated = VK_DOWN; + break; + + case KeyEvent.KEYCODE_GRAVE: + translated = VK_BACK_QUOTE; + break; + + case KeyEvent.KEYCODE_APOSTROPHE: + translated = 0xde; + break; + + case KeyEvent.KEYCODE_BREAK: + translated = VK_PAUSE; + break; + + case KeyEvent.KEYCODE_NUMPAD_DIVIDE: + translated = 0x6F; + break; + + case KeyEvent.KEYCODE_NUMPAD_MULTIPLY: + translated = 0x6A; + break; + + case KeyEvent.KEYCODE_NUMPAD_SUBTRACT: + translated = 0x6D; + break; + + case KeyEvent.KEYCODE_NUMPAD_ADD: + translated = 0x6B; + break; + + case KeyEvent.KEYCODE_NUMPAD_DOT: + translated = 0x6E; + break; + + default: + return 0; + } + } + + return (short) ((KEY_PREFIX << 8) | translated); + } + + @Override + public void onInputDeviceAdded(int index) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + InputDevice device = InputDevice.getDevice(index); + if (device != null && device.getKeyboardType() == InputDevice.KEYBOARD_TYPE_ALPHABETIC) { + keyboardMappings.put(index, new KeyboardMapping(device)); + } + } + } + + @Override + public void onInputDeviceRemoved(int index) { + keyboardMappings.remove(index); + } + + @Override + public void onInputDeviceChanged(int index) { + keyboardMappings.remove(index); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + InputDevice device = InputDevice.getDevice(index); + if (device != null && device.getKeyboardType() == InputDevice.KEYBOARD_TYPE_ALPHABETIC) { + keyboardMappings.set(index, new KeyboardMapping(device)); + } + } + } +} diff --git a/app/src/main/java/com/limelight/binding/input/capture/AndroidNativePointerCaptureProvider.java b/app/src/main/java/com/limelight/binding/input/capture/AndroidNativePointerCaptureProvider.java old mode 100644 new mode 100755 index 589f41375e..19fa3ca77c --- a/app/src/main/java/com/limelight/binding/input/capture/AndroidNativePointerCaptureProvider.java +++ b/app/src/main/java/com/limelight/binding/input/capture/AndroidNativePointerCaptureProvider.java @@ -1,170 +1,170 @@ -package com.limelight.binding.input.capture; - -import android.annotation.TargetApi; -import android.app.Activity; -import android.hardware.input.InputManager; -import android.os.Build; -import android.os.Handler; -import android.view.InputDevice; -import android.view.MotionEvent; -import android.view.View; - - -// We extend AndroidPointerIconCaptureProvider because we want to also get the -// pointer icon hiding behavior over our stream view just in case pointer capture -// is unavailable on this system (ex: DeX, ChromeOS) -@TargetApi(Build.VERSION_CODES.O) -public class AndroidNativePointerCaptureProvider extends AndroidPointerIconCaptureProvider implements InputManager.InputDeviceListener { - private final InputManager inputManager; - private final View targetView; - - public AndroidNativePointerCaptureProvider(Activity activity, View targetView) { - super(activity, targetView); - this.inputManager = activity.getSystemService(InputManager.class); - this.targetView = targetView; - } - - public static boolean isCaptureProviderSupported() { - return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O; - } - - // We only capture the pointer if we have a compatible InputDevice - // present. This is a workaround for an Android 12 regression causing - // incorrect mouse input when using the SPen. - // https://github.com/moonlight-stream/moonlight-android/issues/1030 - private boolean hasCaptureCompatibleInputDevice() { - for (int id : InputDevice.getDeviceIds()) { - InputDevice device = InputDevice.getDevice(id); - if (device == null) { - continue; - } - - // Skip touchscreens when considering compatible capture devices. - // Samsung devices on Android 12 will report a sec_touchpad device - // with SOURCE_TOUCHSCREEN, SOURCE_KEYBOARD, and SOURCE_MOUSE. - // Upon enabling pointer capture, that device will switch to - // SOURCE_KEYBOARD and SOURCE_TOUCHPAD. - // Only skip on non ChromeOS devices cause the ChromeOS pointer else - // gets disabled removing relative mouse capabilities - // on Chromebooks with touchscreens - if (device.supportsSource(InputDevice.SOURCE_TOUCHSCREEN) && !targetView.getContext().getPackageManager().hasSystemFeature("org.chromium.arc.device_management")) { - continue; - } - - if (device.supportsSource(InputDevice.SOURCE_MOUSE) || - device.supportsSource(InputDevice.SOURCE_MOUSE_RELATIVE) || - device.supportsSource(InputDevice.SOURCE_TOUCHPAD)) { - return true; - } - } - - return false; - } - - @Override - public void showCursor() { - super.showCursor(); - - // It is important to unregister the listener *before* releasing pointer capture, - // because releasing pointer capture can cause an onInputDeviceChanged() callback - // for devices with a touchpad (like a DS4 controller). - inputManager.unregisterInputDeviceListener(this); - targetView.releasePointerCapture(); - } - - @Override - public void hideCursor() { - super.hideCursor(); - - // Listen for device events to enable/disable capture - inputManager.registerInputDeviceListener(this, null); - - // Capture now if we have a capture-capable device - if (hasCaptureCompatibleInputDevice()) { - targetView.requestPointerCapture(); - } - } - - @Override - public void onWindowFocusChanged(boolean focusActive) { - // NB: We have to check cursor visibility here because Android pointer capture - // doesn't support capturing the cursor while it's visible. Enabling pointer - // capture implicitly hides the cursor. - if (!focusActive || !isCapturing || isCursorVisible) { - return; - } - - // Recapture the pointer if focus was regained. On Android Q, - // we have to delay a bit before requesting capture because otherwise - // we'll hit the "requestPointerCapture called for a window that has no focus" - // error and it will not actually capture the cursor. - Handler h = new Handler(); - h.postDelayed(new Runnable() { - @Override - public void run() { - if (hasCaptureCompatibleInputDevice()) { - targetView.requestPointerCapture(); - } - } - }, 500); - } - - @Override - public boolean eventHasRelativeMouseAxes(MotionEvent event) { - // SOURCE_MOUSE_RELATIVE is how SOURCE_MOUSE appears when our view has pointer capture. - // SOURCE_TOUCHPAD will have relative axes populated iff our view has pointer capture. - // See https://developer.android.com/reference/android/view/View#requestPointerCapture() - int eventSource = event.getSource(); - return (eventSource == InputDevice.SOURCE_MOUSE_RELATIVE && event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE) || - (eventSource == InputDevice.SOURCE_TOUCHPAD && targetView.hasPointerCapture()); - } - - @Override - public float getRelativeAxisX(MotionEvent event) { - int axis = (event.getSource() == InputDevice.SOURCE_MOUSE_RELATIVE) ? - MotionEvent.AXIS_X : MotionEvent.AXIS_RELATIVE_X; - float x = event.getAxisValue(axis); - for (int i = 0; i < event.getHistorySize(); i++) { - x += event.getHistoricalAxisValue(axis, i); - } - return x; - } - - @Override - public float getRelativeAxisY(MotionEvent event) { - int axis = (event.getSource() == InputDevice.SOURCE_MOUSE_RELATIVE) ? - MotionEvent.AXIS_Y : MotionEvent.AXIS_RELATIVE_Y; - float y = event.getAxisValue(axis); - for (int i = 0; i < event.getHistorySize(); i++) { - y += event.getHistoricalAxisValue(axis, i); - } - return y; - } - - @Override - public void onInputDeviceAdded(int deviceId) { - // Check if we've added a capture-compatible device - if (!targetView.hasPointerCapture() && hasCaptureCompatibleInputDevice()) { - targetView.requestPointerCapture(); - } - } - - @Override - public void onInputDeviceRemoved(int deviceId) { - // Check if the capture-compatible device was removed - if (targetView.hasPointerCapture() && !hasCaptureCompatibleInputDevice()) { - targetView.releasePointerCapture(); - } - } - - @Override - public void onInputDeviceChanged(int deviceId) { - // Emulating a remove+add should be sufficient for our purposes. - // - // Note: This callback must be handled carefully because it can happen as a result of - // calling requestPointerCapture(). This can cause trackpad devices to gain SOURCE_MOUSE_RELATIVE - // and re-enter this callback. - onInputDeviceRemoved(deviceId); - onInputDeviceAdded(deviceId); - } -} +package com.limelight.binding.input.capture; + +import android.annotation.TargetApi; +import android.app.Activity; +import android.hardware.input.InputManager; +import android.os.Build; +import android.os.Handler; +import android.view.InputDevice; +import android.view.MotionEvent; +import android.view.View; + + +// We extend AndroidPointerIconCaptureProvider because we want to also get the +// pointer icon hiding behavior over our stream view just in case pointer capture +// is unavailable on this system (ex: DeX, ChromeOS) +@TargetApi(Build.VERSION_CODES.O) +public class AndroidNativePointerCaptureProvider extends AndroidPointerIconCaptureProvider implements InputManager.InputDeviceListener { + private final InputManager inputManager; + private final View targetView; + + public AndroidNativePointerCaptureProvider(Activity activity, View targetView) { + super(activity, targetView); + this.inputManager = activity.getSystemService(InputManager.class); + this.targetView = targetView; + } + + public static boolean isCaptureProviderSupported() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O; + } + + // We only capture the pointer if we have a compatible InputDevice + // present. This is a workaround for an Android 12 regression causing + // incorrect mouse input when using the SPen. + // https://github.com/moonlight-stream/moonlight-android/issues/1030 + private boolean hasCaptureCompatibleInputDevice() { + for (int id : InputDevice.getDeviceIds()) { + InputDevice device = InputDevice.getDevice(id); + if (device == null) { + continue; + } + + // Skip touchscreens when considering compatible capture devices. + // Samsung devices on Android 12 will report a sec_touchpad device + // with SOURCE_TOUCHSCREEN, SOURCE_KEYBOARD, and SOURCE_MOUSE. + // Upon enabling pointer capture, that device will switch to + // SOURCE_KEYBOARD and SOURCE_TOUCHPAD. + // Only skip on non ChromeOS devices cause the ChromeOS pointer else + // gets disabled removing relative mouse capabilities + // on Chromebooks with touchscreens + if (device.supportsSource(InputDevice.SOURCE_TOUCHSCREEN) && !targetView.getContext().getPackageManager().hasSystemFeature("org.chromium.arc.device_management")) { + continue; + } + + if (device.supportsSource(InputDevice.SOURCE_MOUSE) || + device.supportsSource(InputDevice.SOURCE_MOUSE_RELATIVE) || + device.supportsSource(InputDevice.SOURCE_TOUCHPAD)) { + return true; + } + } + + return false; + } + + @Override + public void showCursor() { + super.showCursor(); + + // It is important to unregister the listener *before* releasing pointer capture, + // because releasing pointer capture can cause an onInputDeviceChanged() callback + // for devices with a touchpad (like a DS4 controller). + inputManager.unregisterInputDeviceListener(this); + targetView.releasePointerCapture(); + } + + @Override + public void hideCursor() { + super.hideCursor(); + + // Listen for device events to enable/disable capture + inputManager.registerInputDeviceListener(this, null); + + // Capture now if we have a capture-capable device + if (hasCaptureCompatibleInputDevice()) { + targetView.requestPointerCapture(); + } + } + + @Override + public void onWindowFocusChanged(boolean focusActive) { + // NB: We have to check cursor visibility here because Android pointer capture + // doesn't support capturing the cursor while it's visible. Enabling pointer + // capture implicitly hides the cursor. + if (!focusActive || !isCapturing || isCursorVisible) { + return; + } + + // Recapture the pointer if focus was regained. On Android Q, + // we have to delay a bit before requesting capture because otherwise + // we'll hit the "requestPointerCapture called for a window that has no focus" + // error and it will not actually capture the cursor. + Handler h = new Handler(); + h.postDelayed(new Runnable() { + @Override + public void run() { + if (hasCaptureCompatibleInputDevice()) { + targetView.requestPointerCapture(); + } + } + }, 500); + } + + @Override + public boolean eventHasRelativeMouseAxes(MotionEvent event) { + // SOURCE_MOUSE_RELATIVE is how SOURCE_MOUSE appears when our view has pointer capture. + // SOURCE_TOUCHPAD will have relative axes populated iff our view has pointer capture. + // See https://developer.android.com/reference/android/view/View#requestPointerCapture() + int eventSource = event.getSource(); + return (eventSource == InputDevice.SOURCE_MOUSE_RELATIVE && event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE) || + (eventSource == InputDevice.SOURCE_TOUCHPAD && targetView.hasPointerCapture()); + } + + @Override + public float getRelativeAxisX(MotionEvent event) { + int axis = (event.getSource() == InputDevice.SOURCE_MOUSE_RELATIVE) ? + MotionEvent.AXIS_X : MotionEvent.AXIS_RELATIVE_X; + float x = event.getAxisValue(axis); + for (int i = 0; i < event.getHistorySize(); i++) { + x += event.getHistoricalAxisValue(axis, i); + } + return x; + } + + @Override + public float getRelativeAxisY(MotionEvent event) { + int axis = (event.getSource() == InputDevice.SOURCE_MOUSE_RELATIVE) ? + MotionEvent.AXIS_Y : MotionEvent.AXIS_RELATIVE_Y; + float y = event.getAxisValue(axis); + for (int i = 0; i < event.getHistorySize(); i++) { + y += event.getHistoricalAxisValue(axis, i); + } + return y; + } + + @Override + public void onInputDeviceAdded(int deviceId) { + // Check if we've added a capture-compatible device + if (!targetView.hasPointerCapture() && hasCaptureCompatibleInputDevice()) { + targetView.requestPointerCapture(); + } + } + + @Override + public void onInputDeviceRemoved(int deviceId) { + // Check if the capture-compatible device was removed + if (targetView.hasPointerCapture() && !hasCaptureCompatibleInputDevice()) { + targetView.releasePointerCapture(); + } + } + + @Override + public void onInputDeviceChanged(int deviceId) { + // Emulating a remove+add should be sufficient for our purposes. + // + // Note: This callback must be handled carefully because it can happen as a result of + // calling requestPointerCapture(). This can cause trackpad devices to gain SOURCE_MOUSE_RELATIVE + // and re-enter this callback. + onInputDeviceRemoved(deviceId); + onInputDeviceAdded(deviceId); + } +} diff --git a/app/src/main/java/com/limelight/binding/input/capture/AndroidPointerIconCaptureProvider.java b/app/src/main/java/com/limelight/binding/input/capture/AndroidPointerIconCaptureProvider.java old mode 100644 new mode 100755 index 6a2d472ae5..bbd1115fc9 --- a/app/src/main/java/com/limelight/binding/input/capture/AndroidPointerIconCaptureProvider.java +++ b/app/src/main/java/com/limelight/binding/input/capture/AndroidPointerIconCaptureProvider.java @@ -1,35 +1,35 @@ -package com.limelight.binding.input.capture; - -import android.annotation.TargetApi; -import android.app.Activity; -import android.content.Context; -import android.os.Build; -import android.view.PointerIcon; -import android.view.View; - -@TargetApi(Build.VERSION_CODES.N) -public class AndroidPointerIconCaptureProvider extends InputCaptureProvider { - private final View targetView; - private final Context context; - - public AndroidPointerIconCaptureProvider(Activity activity, View targetView) { - this.context = activity; - this.targetView = targetView; - } - - public static boolean isCaptureProviderSupported() { - return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N; - } - - @Override - public void hideCursor() { - super.hideCursor(); - targetView.setPointerIcon(PointerIcon.getSystemIcon(context, PointerIcon.TYPE_NULL)); - } - - @Override - public void showCursor() { - super.showCursor(); - targetView.setPointerIcon(null); - } -} +package com.limelight.binding.input.capture; + +import android.annotation.TargetApi; +import android.app.Activity; +import android.content.Context; +import android.os.Build; +import android.view.PointerIcon; +import android.view.View; + +@TargetApi(Build.VERSION_CODES.N) +public class AndroidPointerIconCaptureProvider extends InputCaptureProvider { + private final View targetView; + private final Context context; + + public AndroidPointerIconCaptureProvider(Activity activity, View targetView) { + this.context = activity; + this.targetView = targetView; + } + + public static boolean isCaptureProviderSupported() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N; + } + + @Override + public void hideCursor() { + super.hideCursor(); + targetView.setPointerIcon(PointerIcon.getSystemIcon(context, PointerIcon.TYPE_NULL)); + } + + @Override + public void showCursor() { + super.showCursor(); + targetView.setPointerIcon(null); + } +} diff --git a/app/src/main/java/com/limelight/binding/input/capture/InputCaptureManager.java b/app/src/main/java/com/limelight/binding/input/capture/InputCaptureManager.java old mode 100644 new mode 100755 index 9067b1c762..bd845c83e7 --- a/app/src/main/java/com/limelight/binding/input/capture/InputCaptureManager.java +++ b/app/src/main/java/com/limelight/binding/input/capture/InputCaptureManager.java @@ -1,38 +1,38 @@ -package com.limelight.binding.input.capture; - -import android.app.Activity; - -import com.limelight.BuildConfig; -import com.limelight.LimeLog; -import com.limelight.R; -import com.limelight.binding.input.evdev.EvdevCaptureProviderShim; -import com.limelight.binding.input.evdev.EvdevListener; - -public class InputCaptureManager { - public static InputCaptureProvider getInputCaptureProvider(Activity activity, EvdevListener rootListener) { - if (AndroidNativePointerCaptureProvider.isCaptureProviderSupported()) { - LimeLog.info("Using Android O+ native mouse capture"); - return new AndroidNativePointerCaptureProvider(activity, activity.findViewById(R.id.surfaceView)); - } - // LineageOS implemented broken NVIDIA capture extensions, so avoid using them on root builds. - // See https://github.com/LineageOS/android_frameworks_base/commit/d304f478a023430f4712dbdc3ee69d9ad02cebd3 - else if (!BuildConfig.ROOT_BUILD && ShieldCaptureProvider.isCaptureProviderSupported()) { - LimeLog.info("Using NVIDIA mouse capture extension"); - return new ShieldCaptureProvider(activity); - } - else if (EvdevCaptureProviderShim.isCaptureProviderSupported()) { - LimeLog.info("Using Evdev mouse capture"); - return EvdevCaptureProviderShim.createEvdevCaptureProvider(activity, rootListener); - } - else if (AndroidPointerIconCaptureProvider.isCaptureProviderSupported()) { - // Android N's native capture can't capture over system UI elements - // so we want to only use it if there's no other option. - LimeLog.info("Using Android N+ pointer hiding"); - return new AndroidPointerIconCaptureProvider(activity, activity.findViewById(R.id.surfaceView)); - } - else { - LimeLog.info("Mouse capture not available"); - return new NullCaptureProvider(); - } - } -} +package com.limelight.binding.input.capture; + +import android.app.Activity; + +import com.limelight.BuildConfig; +import com.limelight.LimeLog; +import com.limelight.R; +import com.limelight.binding.input.evdev.EvdevCaptureProviderShim; +import com.limelight.binding.input.evdev.EvdevListener; + +public class InputCaptureManager { + public static InputCaptureProvider getInputCaptureProvider(Activity activity, EvdevListener rootListener) { + if (AndroidNativePointerCaptureProvider.isCaptureProviderSupported()) { + LimeLog.info("Using Android O+ native mouse capture"); + return new AndroidNativePointerCaptureProvider(activity, activity.findViewById(R.id.surfaceView)); + } + // LineageOS implemented broken NVIDIA capture extensions, so avoid using them on root builds. + // See https://github.com/LineageOS/android_frameworks_base/commit/d304f478a023430f4712dbdc3ee69d9ad02cebd3 + else if (!BuildConfig.ROOT_BUILD && ShieldCaptureProvider.isCaptureProviderSupported()) { + LimeLog.info("Using NVIDIA mouse capture extension"); + return new ShieldCaptureProvider(activity); + } + else if (EvdevCaptureProviderShim.isCaptureProviderSupported()) { + LimeLog.info("Using Evdev mouse capture"); + return EvdevCaptureProviderShim.createEvdevCaptureProvider(activity, rootListener); + } + else if (AndroidPointerIconCaptureProvider.isCaptureProviderSupported()) { + // Android N's native capture can't capture over system UI elements + // so we want to only use it if there's no other option. + LimeLog.info("Using Android N+ pointer hiding"); + return new AndroidPointerIconCaptureProvider(activity, activity.findViewById(R.id.surfaceView)); + } + else { + LimeLog.info("Mouse capture not available"); + return new NullCaptureProvider(); + } + } +} diff --git a/app/src/main/java/com/limelight/binding/input/capture/InputCaptureProvider.java b/app/src/main/java/com/limelight/binding/input/capture/InputCaptureProvider.java old mode 100644 new mode 100755 index 3070be5e2c..47c137f619 --- a/app/src/main/java/com/limelight/binding/input/capture/InputCaptureProvider.java +++ b/app/src/main/java/com/limelight/binding/input/capture/InputCaptureProvider.java @@ -1,49 +1,49 @@ -package com.limelight.binding.input.capture; - -import android.view.MotionEvent; - -public abstract class InputCaptureProvider { - protected boolean isCapturing; - protected boolean isCursorVisible; - - public void enableCapture() { - isCapturing = true; - hideCursor(); - } - public void disableCapture() { - isCapturing = false; - showCursor(); - } - - public void destroy() {} - - public boolean isCapturingEnabled() { - return isCapturing; - } - - public boolean isCapturingActive() { - return isCapturing; - } - - public void showCursor() { - isCursorVisible = true; - } - - public void hideCursor() { - isCursorVisible = false; - } - - public boolean eventHasRelativeMouseAxes(MotionEvent event) { - return false; - } - - public float getRelativeAxisX(MotionEvent event) { - return 0; - } - - public float getRelativeAxisY(MotionEvent event) { - return 0; - } - - public void onWindowFocusChanged(boolean focusActive) {} -} +package com.limelight.binding.input.capture; + +import android.view.MotionEvent; + +public abstract class InputCaptureProvider { + protected boolean isCapturing; + protected boolean isCursorVisible; + + public void enableCapture() { + isCapturing = true; + hideCursor(); + } + public void disableCapture() { + isCapturing = false; + showCursor(); + } + + public void destroy() {} + + public boolean isCapturingEnabled() { + return isCapturing; + } + + public boolean isCapturingActive() { + return isCapturing; + } + + public void showCursor() { + isCursorVisible = true; + } + + public void hideCursor() { + isCursorVisible = false; + } + + public boolean eventHasRelativeMouseAxes(MotionEvent event) { + return false; + } + + public float getRelativeAxisX(MotionEvent event) { + return 0; + } + + public float getRelativeAxisY(MotionEvent event) { + return 0; + } + + public void onWindowFocusChanged(boolean focusActive) {} +} diff --git a/app/src/main/java/com/limelight/binding/input/capture/NullCaptureProvider.java b/app/src/main/java/com/limelight/binding/input/capture/NullCaptureProvider.java old mode 100644 new mode 100755 index d8d9cb80b3..8065052dc6 --- a/app/src/main/java/com/limelight/binding/input/capture/NullCaptureProvider.java +++ b/app/src/main/java/com/limelight/binding/input/capture/NullCaptureProvider.java @@ -1,4 +1,4 @@ -package com.limelight.binding.input.capture; - - -public class NullCaptureProvider extends InputCaptureProvider {} +package com.limelight.binding.input.capture; + + +public class NullCaptureProvider extends InputCaptureProvider {} diff --git a/app/src/main/java/com/limelight/binding/input/capture/ShieldCaptureProvider.java b/app/src/main/java/com/limelight/binding/input/capture/ShieldCaptureProvider.java old mode 100644 new mode 100755 index b7f7a86d54..45af8c13db --- a/app/src/main/java/com/limelight/binding/input/capture/ShieldCaptureProvider.java +++ b/app/src/main/java/com/limelight/binding/input/capture/ShieldCaptureProvider.java @@ -1,93 +1,93 @@ -package com.limelight.binding.input.capture; - - -import android.content.Context; -import android.hardware.input.InputManager; -import android.view.MotionEvent; - -import java.lang.reflect.Field; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; - -// NVIDIA extended the Android input APIs with support for using an attached mouse in relative -// mode without having to grab the input device (which requires root). The data comes in the form -// of new AXIS_RELATIVE_X and AXIS_RELATIVE_Y constants in the mouse's MotionEvent objects and -// a new function, InputManager.setCursorVisibility(), that allows the cursor to be hidden. -// -// http://docs.nvidia.com/gameworks/index.html#technologies/mobile/game_controller_handling_mouse.htm - -public class ShieldCaptureProvider extends InputCaptureProvider { - private static boolean nvExtensionSupported; - private static Method methodSetCursorVisibility; - private static int AXIS_RELATIVE_X; - private static int AXIS_RELATIVE_Y; - - private final Context context; - - static { - try { - methodSetCursorVisibility = InputManager.class.getMethod("setCursorVisibility", boolean.class); - - Field fieldRelX = MotionEvent.class.getField("AXIS_RELATIVE_X"); - Field fieldRelY = MotionEvent.class.getField("AXIS_RELATIVE_Y"); - - AXIS_RELATIVE_X = (Integer) fieldRelX.get(null); - AXIS_RELATIVE_Y = (Integer) fieldRelY.get(null); - - nvExtensionSupported = true; - } catch (Exception e) { - nvExtensionSupported = false; - } - } - - public ShieldCaptureProvider(Context context) { - this.context = context; - } - - public static boolean isCaptureProviderSupported() { - return nvExtensionSupported; - } - - private boolean setCursorVisibility(boolean visible) { - try { - methodSetCursorVisibility.invoke(context.getSystemService(Context.INPUT_SERVICE), visible); - return true; - } catch (InvocationTargetException e) { - e.printStackTrace(); - } catch (IllegalAccessException e) { - e.printStackTrace(); - } - - return false; - } - - @Override - public void hideCursor() { - super.hideCursor(); - setCursorVisibility(false); - } - - @Override - public void showCursor() { - super.showCursor(); - setCursorVisibility(true); - } - - @Override - public boolean eventHasRelativeMouseAxes(MotionEvent event) { - // All mouse events should use relative axes, even if they are zero. This avoids triggering - // cursor jumps if we get an event with no associated motion, like ACTION_DOWN or ACTION_UP. - return event.getPointerCount() == 1 && event.getActionIndex() == 0 && - event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE; - } - - @Override - public float getRelativeAxisX(MotionEvent event) { - return event.getAxisValue(AXIS_RELATIVE_X); - } - - @Override - public float getRelativeAxisY(MotionEvent event) { - return event.getAxisValue(AXIS_RELATIVE_Y); - } -} +package com.limelight.binding.input.capture; + + +import android.content.Context; +import android.hardware.input.InputManager; +import android.view.MotionEvent; + +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +// NVIDIA extended the Android input APIs with support for using an attached mouse in relative +// mode without having to grab the input device (which requires root). The data comes in the form +// of new AXIS_RELATIVE_X and AXIS_RELATIVE_Y constants in the mouse's MotionEvent objects and +// a new function, InputManager.setCursorVisibility(), that allows the cursor to be hidden. +// +// http://docs.nvidia.com/gameworks/index.html#technologies/mobile/game_controller_handling_mouse.htm + +public class ShieldCaptureProvider extends InputCaptureProvider { + private static boolean nvExtensionSupported; + private static Method methodSetCursorVisibility; + private static int AXIS_RELATIVE_X; + private static int AXIS_RELATIVE_Y; + + private final Context context; + + static { + try { + methodSetCursorVisibility = InputManager.class.getMethod("setCursorVisibility", boolean.class); + + Field fieldRelX = MotionEvent.class.getField("AXIS_RELATIVE_X"); + Field fieldRelY = MotionEvent.class.getField("AXIS_RELATIVE_Y"); + + AXIS_RELATIVE_X = (Integer) fieldRelX.get(null); + AXIS_RELATIVE_Y = (Integer) fieldRelY.get(null); + + nvExtensionSupported = true; + } catch (Exception e) { + nvExtensionSupported = false; + } + } + + public ShieldCaptureProvider(Context context) { + this.context = context; + } + + public static boolean isCaptureProviderSupported() { + return nvExtensionSupported; + } + + private boolean setCursorVisibility(boolean visible) { + try { + methodSetCursorVisibility.invoke(context.getSystemService(Context.INPUT_SERVICE), visible); + return true; + } catch (InvocationTargetException e) { + e.printStackTrace(); + } catch (IllegalAccessException e) { + e.printStackTrace(); + } + + return false; + } + + @Override + public void hideCursor() { + super.hideCursor(); + setCursorVisibility(false); + } + + @Override + public void showCursor() { + super.showCursor(); + setCursorVisibility(true); + } + + @Override + public boolean eventHasRelativeMouseAxes(MotionEvent event) { + // All mouse events should use relative axes, even if they are zero. This avoids triggering + // cursor jumps if we get an event with no associated motion, like ACTION_DOWN or ACTION_UP. + return event.getPointerCount() == 1 && event.getActionIndex() == 0 && + event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE; + } + + @Override + public float getRelativeAxisX(MotionEvent event) { + return event.getAxisValue(AXIS_RELATIVE_X); + } + + @Override + public float getRelativeAxisY(MotionEvent event) { + return event.getAxisValue(AXIS_RELATIVE_Y); + } +} diff --git a/app/src/main/java/com/limelight/binding/input/driver/AbstractController.java b/app/src/main/java/com/limelight/binding/input/driver/AbstractController.java old mode 100644 new mode 100755 index fe31ca679c..12c65698e8 --- a/app/src/main/java/com/limelight/binding/input/driver/AbstractController.java +++ b/app/src/main/java/com/limelight/binding/input/driver/AbstractController.java @@ -1,77 +1,77 @@ -package com.limelight.binding.input.driver; - -public abstract class AbstractController { - - private final int deviceId; - private final int vendorId; - private final int productId; - - private UsbDriverListener listener; - - protected int buttonFlags, supportedButtonFlags; - protected float leftTrigger, rightTrigger; - protected float rightStickX, rightStickY; - protected float leftStickX, leftStickY; - protected short capabilities; - protected byte type; - - public int getControllerId() { - return deviceId; - } - - public int getVendorId() { - return vendorId; - } - - public int getProductId() { - return productId; - } - - public int getSupportedButtonFlags() { - return supportedButtonFlags; - } - - public short getCapabilities() { - return capabilities; - } - - public byte getType() { - return type; - } - - protected void setButtonFlag(int buttonFlag, int data) { - if (data != 0) { - buttonFlags |= buttonFlag; - } - else { - buttonFlags &= ~buttonFlag; - } - } - - protected void reportInput() { - listener.reportControllerState(deviceId, buttonFlags, leftStickX, leftStickY, - rightStickX, rightStickY, leftTrigger, rightTrigger); - } - - public abstract boolean start(); - public abstract void stop(); - - public AbstractController(int deviceId, UsbDriverListener listener, int vendorId, int productId) { - this.deviceId = deviceId; - this.listener = listener; - this.vendorId = vendorId; - this.productId = productId; - } - - public abstract void rumble(short lowFreqMotor, short highFreqMotor); - - public abstract void rumbleTriggers(short leftTrigger, short rightTrigger); - - protected void notifyDeviceRemoved() { - listener.deviceRemoved(this); - } - - protected void notifyDeviceAdded() { - listener.deviceAdded(this); - } -} +package com.limelight.binding.input.driver; + +public abstract class AbstractController { + + private final int deviceId; + private final int vendorId; + private final int productId; + + private UsbDriverListener listener; + + protected int buttonFlags, supportedButtonFlags; + protected float leftTrigger, rightTrigger; + protected float rightStickX, rightStickY; + protected float leftStickX, leftStickY; + protected short capabilities; + protected byte type; + + public int getControllerId() { + return deviceId; + } + + public int getVendorId() { + return vendorId; + } + + public int getProductId() { + return productId; + } + + public int getSupportedButtonFlags() { + return supportedButtonFlags; + } + + public short getCapabilities() { + return capabilities; + } + + public byte getType() { + return type; + } + + protected void setButtonFlag(int buttonFlag, int data) { + if (data != 0) { + buttonFlags |= buttonFlag; + } + else { + buttonFlags &= ~buttonFlag; + } + } + + protected void reportInput() { + listener.reportControllerState(deviceId, buttonFlags, leftStickX, leftStickY, + rightStickX, rightStickY, leftTrigger, rightTrigger); + } + + public abstract boolean start(); + public abstract void stop(); + + public AbstractController(int deviceId, UsbDriverListener listener, int vendorId, int productId) { + this.deviceId = deviceId; + this.listener = listener; + this.vendorId = vendorId; + this.productId = productId; + } + + public abstract void rumble(short lowFreqMotor, short highFreqMotor); + + public abstract void rumbleTriggers(short leftTrigger, short rightTrigger); + + protected void notifyDeviceRemoved() { + listener.deviceRemoved(this); + } + + protected void notifyDeviceAdded() { + listener.deviceAdded(this); + } +} diff --git a/app/src/main/java/com/limelight/binding/input/driver/AbstractXboxController.java b/app/src/main/java/com/limelight/binding/input/driver/AbstractXboxController.java old mode 100644 new mode 100755 index 4525b4fb91..3f42d2f6b4 --- a/app/src/main/java/com/limelight/binding/input/driver/AbstractXboxController.java +++ b/app/src/main/java/com/limelight/binding/input/driver/AbstractXboxController.java @@ -1,173 +1,173 @@ -package com.limelight.binding.input.driver; - -import android.hardware.usb.UsbConstants; -import android.hardware.usb.UsbDevice; -import android.hardware.usb.UsbDeviceConnection; -import android.hardware.usb.UsbEndpoint; -import android.hardware.usb.UsbInterface; -import android.os.SystemClock; - -import com.limelight.LimeLog; -import com.limelight.nvstream.input.ControllerPacket; -import com.limelight.nvstream.jni.MoonBridge; - -import java.nio.ByteBuffer; -import java.nio.ByteOrder; - -public abstract class AbstractXboxController extends AbstractController { - protected final UsbDevice device; - protected final UsbDeviceConnection connection; - - private Thread inputThread; - private boolean stopped; - - protected UsbEndpoint inEndpt, outEndpt; - - public AbstractXboxController(UsbDevice device, UsbDeviceConnection connection, int deviceId, UsbDriverListener listener) { - super(deviceId, listener, device.getVendorId(), device.getProductId()); - this.device = device; - this.connection = connection; - this.type = MoonBridge.LI_CTYPE_XBOX; - this.capabilities = MoonBridge.LI_CCAP_ANALOG_TRIGGERS | MoonBridge.LI_CCAP_RUMBLE; - this.buttonFlags = - ControllerPacket.A_FLAG | ControllerPacket.B_FLAG | ControllerPacket.X_FLAG | ControllerPacket.Y_FLAG | - ControllerPacket.UP_FLAG | ControllerPacket.DOWN_FLAG | ControllerPacket.LEFT_FLAG | ControllerPacket.RIGHT_FLAG | - ControllerPacket.LB_FLAG | ControllerPacket.RB_FLAG | - ControllerPacket.LS_CLK_FLAG | ControllerPacket.RS_CLK_FLAG | - ControllerPacket.BACK_FLAG | ControllerPacket.PLAY_FLAG | ControllerPacket.SPECIAL_BUTTON_FLAG; - } - - private Thread createInputThread() { - return new Thread() { - public void run() { - try { - // Delay for a moment before reporting the new gamepad and - // accepting new input. This allows time for the old InputDevice - // to go away before we reclaim its spot. If the old device is still - // around when we call notifyDeviceAdded(), we won't be able to claim - // the controller number used by the original InputDevice. - Thread.sleep(1000); - } catch (InterruptedException e) { - return; - } - - // Report that we're added _before_ reporting input - notifyDeviceAdded(); - - while (!isInterrupted() && !stopped) { - byte[] buffer = new byte[64]; - - int res; - - // - // There's no way that I can tell to determine if a device has failed - // or if the timeout has simply expired. We'll check how long the transfer - // took to fail and assume the device failed if it happened before the timeout - // expired. - // - - do { - // Read the next input state packet - long lastMillis = SystemClock.uptimeMillis(); - res = connection.bulkTransfer(inEndpt, buffer, buffer.length, 3000); - - // If we get a zero length response, treat it as an error - if (res == 0) { - res = -1; - } - - if (res == -1 && SystemClock.uptimeMillis() - lastMillis < 1000) { - LimeLog.warning("Detected device I/O error"); - AbstractXboxController.this.stop(); - break; - } - } while (res == -1 && !isInterrupted() && !stopped); - - if (res == -1 || stopped) { - break; - } - - if (handleRead(ByteBuffer.wrap(buffer, 0, res).order(ByteOrder.LITTLE_ENDIAN))) { - // Report input if handleRead() returns true - reportInput(); - } - } - } - }; - } - - public boolean start() { - // Force claim all interfaces - for (int i = 0; i < device.getInterfaceCount(); i++) { - UsbInterface iface = device.getInterface(i); - - if (!connection.claimInterface(iface, true)) { - LimeLog.warning("Failed to claim interfaces"); - return false; - } - } - - // Find the endpoints - UsbInterface iface = device.getInterface(0); - for (int i = 0; i < iface.getEndpointCount(); i++) { - UsbEndpoint endpt = iface.getEndpoint(i); - if (endpt.getDirection() == UsbConstants.USB_DIR_IN) { - if (inEndpt != null) { - LimeLog.warning("Found duplicate IN endpoint"); - return false; - } - inEndpt = endpt; - } - else if (endpt.getDirection() == UsbConstants.USB_DIR_OUT) { - if (outEndpt != null) { - LimeLog.warning("Found duplicate OUT endpoint"); - return false; - } - outEndpt = endpt; - } - } - - // Make sure the required endpoints were present - if (inEndpt == null || outEndpt == null) { - LimeLog.warning("Missing required endpoint"); - return false; - } - - // Run the init function - if (!doInit()) { - return false; - } - - // Start listening for controller input - inputThread = createInputThread(); - inputThread.start(); - - return true; - } - - public void stop() { - if (stopped) { - return; - } - - stopped = true; - - // Cancel any rumble effects - rumble((short)0, (short)0); - - // Stop the input thread - if (inputThread != null) { - inputThread.interrupt(); - inputThread = null; - } - - // Close the USB connection - connection.close(); - - // Report the device removed - notifyDeviceRemoved(); - } - - protected abstract boolean handleRead(ByteBuffer buffer); - protected abstract boolean doInit(); -} +package com.limelight.binding.input.driver; + +import android.hardware.usb.UsbConstants; +import android.hardware.usb.UsbDevice; +import android.hardware.usb.UsbDeviceConnection; +import android.hardware.usb.UsbEndpoint; +import android.hardware.usb.UsbInterface; +import android.os.SystemClock; + +import com.limelight.LimeLog; +import com.limelight.nvstream.input.ControllerPacket; +import com.limelight.nvstream.jni.MoonBridge; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +public abstract class AbstractXboxController extends AbstractController { + protected final UsbDevice device; + protected final UsbDeviceConnection connection; + + private Thread inputThread; + private boolean stopped; + + protected UsbEndpoint inEndpt, outEndpt; + + public AbstractXboxController(UsbDevice device, UsbDeviceConnection connection, int deviceId, UsbDriverListener listener) { + super(deviceId, listener, device.getVendorId(), device.getProductId()); + this.device = device; + this.connection = connection; + this.type = MoonBridge.LI_CTYPE_XBOX; + this.capabilities = MoonBridge.LI_CCAP_ANALOG_TRIGGERS | MoonBridge.LI_CCAP_RUMBLE; + this.buttonFlags = + ControllerPacket.A_FLAG | ControllerPacket.B_FLAG | ControllerPacket.X_FLAG | ControllerPacket.Y_FLAG | + ControllerPacket.UP_FLAG | ControllerPacket.DOWN_FLAG | ControllerPacket.LEFT_FLAG | ControllerPacket.RIGHT_FLAG | + ControllerPacket.LB_FLAG | ControllerPacket.RB_FLAG | + ControllerPacket.LS_CLK_FLAG | ControllerPacket.RS_CLK_FLAG | + ControllerPacket.BACK_FLAG | ControllerPacket.PLAY_FLAG | ControllerPacket.SPECIAL_BUTTON_FLAG; + } + + private Thread createInputThread() { + return new Thread() { + public void run() { + try { + // Delay for a moment before reporting the new gamepad and + // accepting new input. This allows time for the old InputDevice + // to go away before we reclaim its spot. If the old device is still + // around when we call notifyDeviceAdded(), we won't be able to claim + // the controller number used by the original InputDevice. + Thread.sleep(1000); + } catch (InterruptedException e) { + return; + } + + // Report that we're added _before_ reporting input + notifyDeviceAdded(); + + while (!isInterrupted() && !stopped) { + byte[] buffer = new byte[64]; + + int res; + + // + // There's no way that I can tell to determine if a device has failed + // or if the timeout has simply expired. We'll check how long the transfer + // took to fail and assume the device failed if it happened before the timeout + // expired. + // + + do { + // Read the next input state packet + long lastMillis = SystemClock.uptimeMillis(); + res = connection.bulkTransfer(inEndpt, buffer, buffer.length, 3000); + + // If we get a zero length response, treat it as an error + if (res == 0) { + res = -1; + } + + if (res == -1 && SystemClock.uptimeMillis() - lastMillis < 1000) { + LimeLog.warning("Detected device I/O error"); + AbstractXboxController.this.stop(); + break; + } + } while (res == -1 && !isInterrupted() && !stopped); + + if (res == -1 || stopped) { + break; + } + + if (handleRead(ByteBuffer.wrap(buffer, 0, res).order(ByteOrder.LITTLE_ENDIAN))) { + // Report input if handleRead() returns true + reportInput(); + } + } + } + }; + } + + public boolean start() { + // Force claim all interfaces + for (int i = 0; i < device.getInterfaceCount(); i++) { + UsbInterface iface = device.getInterface(i); + + if (!connection.claimInterface(iface, true)) { + LimeLog.warning("Failed to claim interfaces"); + return false; + } + } + + // Find the endpoints + UsbInterface iface = device.getInterface(0); + for (int i = 0; i < iface.getEndpointCount(); i++) { + UsbEndpoint endpt = iface.getEndpoint(i); + if (endpt.getDirection() == UsbConstants.USB_DIR_IN) { + if (inEndpt != null) { + LimeLog.warning("Found duplicate IN endpoint"); + return false; + } + inEndpt = endpt; + } + else if (endpt.getDirection() == UsbConstants.USB_DIR_OUT) { + if (outEndpt != null) { + LimeLog.warning("Found duplicate OUT endpoint"); + return false; + } + outEndpt = endpt; + } + } + + // Make sure the required endpoints were present + if (inEndpt == null || outEndpt == null) { + LimeLog.warning("Missing required endpoint"); + return false; + } + + // Run the init function + if (!doInit()) { + return false; + } + + // Start listening for controller input + inputThread = createInputThread(); + inputThread.start(); + + return true; + } + + public void stop() { + if (stopped) { + return; + } + + stopped = true; + + // Cancel any rumble effects + rumble((short)0, (short)0); + + // Stop the input thread + if (inputThread != null) { + inputThread.interrupt(); + inputThread = null; + } + + // Close the USB connection + connection.close(); + + // Report the device removed + notifyDeviceRemoved(); + } + + protected abstract boolean handleRead(ByteBuffer buffer); + protected abstract boolean doInit(); +} diff --git a/app/src/main/java/com/limelight/binding/input/driver/UsbDriverListener.java b/app/src/main/java/com/limelight/binding/input/driver/UsbDriverListener.java old mode 100644 new mode 100755 index c812122299..1ac27df93f --- a/app/src/main/java/com/limelight/binding/input/driver/UsbDriverListener.java +++ b/app/src/main/java/com/limelight/binding/input/driver/UsbDriverListener.java @@ -1,11 +1,11 @@ -package com.limelight.binding.input.driver; - -public interface UsbDriverListener { - void reportControllerState(int controllerId, int buttonFlags, - float leftStickX, float leftStickY, - float rightStickX, float rightStickY, - float leftTrigger, float rightTrigger); - - void deviceRemoved(AbstractController controller); - void deviceAdded(AbstractController controller); -} +package com.limelight.binding.input.driver; + +public interface UsbDriverListener { + void reportControllerState(int controllerId, int buttonFlags, + float leftStickX, float leftStickY, + float rightStickX, float rightStickY, + float leftTrigger, float rightTrigger); + + void deviceRemoved(AbstractController controller); + void deviceAdded(AbstractController controller); +} diff --git a/app/src/main/java/com/limelight/binding/input/driver/UsbDriverService.java b/app/src/main/java/com/limelight/binding/input/driver/UsbDriverService.java old mode 100644 new mode 100755 index f81221a548..f6ba35c6ce --- a/app/src/main/java/com/limelight/binding/input/driver/UsbDriverService.java +++ b/app/src/main/java/com/limelight/binding/input/driver/UsbDriverService.java @@ -1,353 +1,354 @@ -package com.limelight.binding.input.driver; - -import android.annotation.SuppressLint; -import android.app.PendingIntent; -import android.app.Service; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.hardware.usb.UsbDevice; -import android.hardware.usb.UsbDeviceConnection; -import android.hardware.usb.UsbManager; -import android.os.Binder; -import android.os.Build; -import android.os.Handler; -import android.os.IBinder; -import android.view.InputDevice; -import android.widget.Toast; - -import com.limelight.LimeLog; -import com.limelight.R; -import com.limelight.preferences.PreferenceConfiguration; - -import java.io.File; -import java.util.ArrayList; - -public class UsbDriverService extends Service implements UsbDriverListener { - - private static final String ACTION_USB_PERMISSION = - "com.limelight.USB_PERMISSION"; - - private UsbManager usbManager; - private PreferenceConfiguration prefConfig; - private boolean started; - - private final UsbEventReceiver receiver = new UsbEventReceiver(); - private final UsbDriverBinder binder = new UsbDriverBinder(); - - private final ArrayList controllers = new ArrayList<>(); - - private UsbDriverListener listener; - private UsbDriverStateListener stateListener; - private int nextDeviceId; - - @Override - public void reportControllerState(int controllerId, int buttonFlags, float leftStickX, float leftStickY, - float rightStickX, float rightStickY, float leftTrigger, float rightTrigger) { - // Call through to the client's listener - if (listener != null) { - listener.reportControllerState(controllerId, buttonFlags, leftStickX, leftStickY, rightStickX, rightStickY, leftTrigger, rightTrigger); - } - } - - @Override - public void deviceRemoved(AbstractController controller) { - // Remove the the controller from our list (if not removed already) - controllers.remove(controller); - - // Call through to the client's listener - if (listener != null) { - listener.deviceRemoved(controller); - } - } - - @Override - public void deviceAdded(AbstractController controller) { - // Call through to the client's listener - if (listener != null) { - listener.deviceAdded(controller); - } - } - - public class UsbEventReceiver extends BroadcastReceiver { - @Override - public void onReceive(Context context, Intent intent) { - String action = intent.getAction(); - - // Initial attachment broadcast - if (action.equals(UsbManager.ACTION_USB_DEVICE_ATTACHED)) { - final UsbDevice device = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); - - // shouldClaimDevice() looks at the kernel's enumerated input - // devices to make its decision about whether to prompt to take - // control of the device. The kernel bringing up the input stack - // may race with this callback and cause us to prompt when the - // kernel is capable of running the device. Let's post a delayed - // message to process this state change to allow the kernel - // some time to bring up the stack. - new Handler().postDelayed(new Runnable() { - @Override - public void run() { - // Continue the state machine - handleUsbDeviceState(device); - } - }, 1000); - } - // Subsequent permission dialog completion intent - else if (action.equals(ACTION_USB_PERMISSION)) { - UsbDevice device = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); - - // Permission dialog is now closed - if (stateListener != null) { - stateListener.onUsbPermissionPromptCompleted(); - } - - // If we got this far, we've already found we're able to handle this device - if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) { - handleUsbDeviceState(device); - } - } - } - } - - public class UsbDriverBinder extends Binder { - public void setListener(UsbDriverListener listener) { - UsbDriverService.this.listener = listener; - - // Report all controllerMap that already exist - if (listener != null) { - for (AbstractController controller : controllers) { - listener.deviceAdded(controller); - } - } - } - - public void setStateListener(UsbDriverStateListener stateListener) { - UsbDriverService.this.stateListener = stateListener; - } - - public void start() { - UsbDriverService.this.start(); - } - - public void stop() { - UsbDriverService.this.stop(); - } - } - - private void handleUsbDeviceState(UsbDevice device) { - // Are we able to operate it? - if (shouldClaimDevice(device, prefConfig.bindAllUsb)) { - // Do we have permission yet? - if (!usbManager.hasPermission(device)) { - // Let's ask for permission - try { - // Tell the state listener that we're about to display a permission dialog - if (stateListener != null) { - stateListener.onUsbPermissionPromptStarting(); - } - - int intentFlags = 0; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - // This PendingIntent must be mutable to allow the framework to populate EXTRA_DEVICE and EXTRA_PERMISSION_GRANTED. - intentFlags |= PendingIntent.FLAG_MUTABLE; - } - - // This function is not documented as throwing any exceptions (denying access - // is indicated by calling the PendingIntent with a false result). However, - // Samsung Knox has some policies which block this request, but rather than - // just returning a false result or returning 0 enumerated devices, - // they throw an undocumented SecurityException from this call, crashing - // the whole app. :( - - // Use an explicit intent to activate our unexported broadcast receiver, as required on Android 14+ - Intent i = new Intent(ACTION_USB_PERMISSION); - i.setPackage(getPackageName()); - - usbManager.requestPermission(device, PendingIntent.getBroadcast(UsbDriverService.this, 0, i, intentFlags)); - } catch (SecurityException e) { - Toast.makeText(this, this.getText(R.string.error_usb_prohibited), Toast.LENGTH_LONG).show(); - if (stateListener != null) { - stateListener.onUsbPermissionPromptCompleted(); - } - } - return; - } - - // Open the device - UsbDeviceConnection connection = usbManager.openDevice(device); - if (connection == null) { - LimeLog.warning("Unable to open USB device: "+device.getDeviceName()); - return; - } - - - AbstractController controller; - - if (XboxOneController.canClaimDevice(device)) { - controller = new XboxOneController(device, connection, nextDeviceId++, this); - } - else if (Xbox360Controller.canClaimDevice(device)) { - controller = new Xbox360Controller(device, connection, nextDeviceId++, this); - } - else if (Xbox360WirelessDongle.canClaimDevice(device)) { - controller = new Xbox360WirelessDongle(device, connection, nextDeviceId++, this); - } - else { - // Unreachable - return; - } - - // Start the controller - if (!controller.start()) { - connection.close(); - return; - } - - // Add this controller to the list - controllers.add(controller); - } - } - - public static boolean isRecognizedInputDevice(UsbDevice device) { - // Determine if this VID and PID combo matches an existing input device - // and defer to the built-in controller support in that case. - for (int id : InputDevice.getDeviceIds()) { - InputDevice inputDev = InputDevice.getDevice(id); - if (inputDev == null) { - // Device was removed while looping - continue; - } - - if (inputDev.getVendorId() == device.getVendorId() && - inputDev.getProductId() == device.getProductId()) { - return true; - } - } - - return false; - } - - public static boolean kernelSupportsXboxOne() { - String kernelVersion = System.getProperty("os.version"); - LimeLog.info("Kernel Version: "+kernelVersion); - - if (kernelVersion == null) { - // We'll assume this is some newer version of Android - // that doesn't let you read the kernel version this way. - return true; - } - else if (kernelVersion.startsWith("2.") || kernelVersion.startsWith("3.")) { - // These are old kernels that definitely don't support Xbox One controllers properly - return false; - } - else if (kernelVersion.startsWith("4.4.") || kernelVersion.startsWith("4.9.")) { - // These aren't guaranteed to have backported kernel patches for proper Xbox One - // support (though some devices will). - return false; - } - else { - // The next AOSP common kernel is 4.14 which has working Xbox One controller support - return true; - } - } - - public static boolean kernelSupportsXbox360W() { - // Check if this kernel is 4.2+ to see if the xpad driver sets Xbox 360 wireless LEDs - // https://github.com/torvalds/linux/commit/75b7f05d2798ee3a1cc5bbdd54acd0e318a80396 - String kernelVersion = System.getProperty("os.version"); - if (kernelVersion != null) { - if (kernelVersion.startsWith("2.") || kernelVersion.startsWith("3.") || - kernelVersion.startsWith("4.0.") || kernelVersion.startsWith("4.1.")) { - // Even if LED devices are present, the driver won't set the initial LED state. - return false; - } - } - - // We know we have a kernel that should set Xbox 360 wireless LEDs, but we still don't - // know if CONFIG_JOYSTICK_XPAD_LEDS was enabled during the kernel build. Unfortunately - // it's not possible to detect this reliably due to Android's app sandboxing. Reading - // /proc/config.gz and enumerating /sys/class/leds are both blocked by SELinux on any - // relatively modern device. We will assume that CONFIG_JOYSTICK_XPAD_LEDS=y on these - // kernels and users can override by using the settings option to claim all devices. - return true; - } - - public static boolean shouldClaimDevice(UsbDevice device, boolean claimAllAvailable) { - return ((!kernelSupportsXboxOne() || !isRecognizedInputDevice(device) || claimAllAvailable) && XboxOneController.canClaimDevice(device)) || - ((!isRecognizedInputDevice(device) || claimAllAvailable) && Xbox360Controller.canClaimDevice(device)) || - // We must not call isRecognizedInputDevice() because wireless controllers don't share the same product ID as the dongle - ((!kernelSupportsXbox360W() || claimAllAvailable) && Xbox360WirelessDongle.canClaimDevice(device)); - } - - @SuppressLint("UnspecifiedRegisterReceiverFlag") - private void start() { - if (started || usbManager == null) { - return; - } - - started = true; - - // Register for USB attach broadcasts and permission completions - IntentFilter filter = new IntentFilter(); - filter.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED); - filter.addAction(ACTION_USB_PERMISSION); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - registerReceiver(receiver, filter, RECEIVER_NOT_EXPORTED); - } - else { - registerReceiver(receiver, filter); - } - - // Enumerate existing devices - for (UsbDevice dev : usbManager.getDeviceList().values()) { - if (shouldClaimDevice(dev, prefConfig.bindAllUsb)) { - // Start the process of claiming this device - handleUsbDeviceState(dev); - } - } - } - - private void stop() { - if (!started) { - return; - } - - started = false; - - // Stop the attachment receiver - unregisterReceiver(receiver); - - // Stop all controllers - while (controllers.size() > 0) { - // Stop and remove the controller - controllers.remove(0).stop(); - } - } - - @Override - public void onCreate() { - this.usbManager = (UsbManager) getSystemService(Context.USB_SERVICE); - this.prefConfig = PreferenceConfiguration.readPreferences(this); - } - - @Override - public void onDestroy() { - stop(); - - // Remove listeners - listener = null; - stateListener = null; - } - - @Override - public IBinder onBind(Intent intent) { - return binder; - } - - public interface UsbDriverStateListener { - void onUsbPermissionPromptStarting(); - void onUsbPermissionPromptCompleted(); - } -} +package com.limelight.binding.input.driver; + +import android.annotation.SuppressLint; +import android.app.PendingIntent; +import android.app.Service; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.hardware.usb.UsbDevice; +import android.hardware.usb.UsbDeviceConnection; +import android.hardware.usb.UsbManager; +import android.os.Binder; +import android.os.Build; +import android.os.Handler; +import android.os.IBinder; +import android.view.InputDevice; +import android.widget.Toast; + +import com.limelight.LimeLog; +import com.limelight.R; +import com.limelight.preferences.PreferenceConfiguration; + +import java.io.File; +import java.util.ArrayList; + +public class UsbDriverService extends Service implements UsbDriverListener { + + private static final String ACTION_USB_PERMISSION = + "com.limelight.USB_PERMISSION"; + + private UsbManager usbManager; + private PreferenceConfiguration prefConfig; + private boolean started; + + private final UsbEventReceiver receiver = new UsbEventReceiver(); + private final UsbDriverBinder binder = new UsbDriverBinder(); + + private final ArrayList controllers = new ArrayList<>(); + + private UsbDriverListener listener; + private UsbDriverStateListener stateListener; + private int nextDeviceId; + + @Override + public void reportControllerState(int controllerId, int buttonFlags, float leftStickX, float leftStickY, + float rightStickX, float rightStickY, float leftTrigger, float rightTrigger) { + // Call through to the client's listener + if (listener != null) { + listener.reportControllerState(controllerId, buttonFlags, leftStickX, leftStickY, rightStickX, rightStickY, leftTrigger, rightTrigger); + } + } + + @Override + public void deviceRemoved(AbstractController controller) { + // Remove the the controller from our list (if not removed already) + controllers.remove(controller); + + // Call through to the client's listener + if (listener != null) { + listener.deviceRemoved(controller); + } + } + + @Override + public void deviceAdded(AbstractController controller) { + // Call through to the client's listener + if (listener != null) { + listener.deviceAdded(controller); + } + } + + public class UsbEventReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + + // Initial attachment broadcast + if (action.equals(UsbManager.ACTION_USB_DEVICE_ATTACHED)) { + final UsbDevice device = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); + + // shouldClaimDevice() looks at the kernel's enumerated input + // devices to make its decision about whether to prompt to take + // control of the device. The kernel bringing up the input stack + // may race with this callback and cause us to prompt when the + // kernel is capable of running the device. Let's post a delayed + // message to process this state change to allow the kernel + // some time to bring up the stack. + new Handler().postDelayed(new Runnable() { + @Override + public void run() { + // Continue the state machine + handleUsbDeviceState(device); + } + }, 1000); + } + // Subsequent permission dialog completion intent + else if (action.equals(ACTION_USB_PERMISSION)) { + UsbDevice device = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); + + // Permission dialog is now closed + if (stateListener != null) { + stateListener.onUsbPermissionPromptCompleted(); + } + + // If we got this far, we've already found we're able to handle this device + if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) { + handleUsbDeviceState(device); + } + } + } + } + + public class UsbDriverBinder extends Binder { + public void setListener(UsbDriverListener listener) { + UsbDriverService.this.listener = listener; + + // Report all controllerMap that already exist + if (listener != null) { + for (AbstractController controller : controllers) { + listener.deviceAdded(controller); + } + } + } + + public void setStateListener(UsbDriverStateListener stateListener) { + UsbDriverService.this.stateListener = stateListener; + } + + public void start() { + UsbDriverService.this.start(); + } + + public void stop() { + UsbDriverService.this.stop(); + } + } + + private void handleUsbDeviceState(UsbDevice device) { + // Are we able to operate it? + if (shouldClaimDevice(device, prefConfig.bindAllUsb)) { + // Do we have permission yet? + if (!usbManager.hasPermission(device)) { + // Let's ask for permission + try { + // Tell the state listener that we're about to display a permission dialog + if (stateListener != null) { + stateListener.onUsbPermissionPromptStarting(); + } + + int intentFlags = 0; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + // This PendingIntent must be mutable to allow the framework to populate EXTRA_DEVICE and EXTRA_PERMISSION_GRANTED. + intentFlags |= PendingIntent.FLAG_MUTABLE; + } + + // This function is not documented as throwing any exceptions (denying access + // is indicated by calling the PendingIntent with a false result). However, + // Samsung Knox has some policies which block this request, but rather than + // just returning a false result or returning 0 enumerated devices, + // they throw an undocumented SecurityException from this call, crashing + // the whole app. :( + + // Use an explicit intent to activate our unexported broadcast receiver, as required on Android 14+ + Intent i = new Intent(ACTION_USB_PERMISSION); + i.setPackage(getPackageName()); + + usbManager.requestPermission(device, PendingIntent.getBroadcast(UsbDriverService.this, 0, i, intentFlags)); + } catch (SecurityException e) { + Toast.makeText(this, this.getText(R.string.error_usb_prohibited), Toast.LENGTH_LONG).show(); + if (stateListener != null) { + stateListener.onUsbPermissionPromptCompleted(); + } + } + return; + } + + // Open the device + UsbDeviceConnection connection = usbManager.openDevice(device); + if (connection == null) { + LimeLog.warning("Unable to open USB device: "+device.getDeviceName()); + return; + } + + + AbstractController controller; + + if (XboxOneController.canClaimDevice(device)) { + controller = new XboxOneController(device, connection, nextDeviceId++, this); + } + else if (Xbox360Controller.canClaimDevice(device)) { + controller = new Xbox360Controller(device, connection, nextDeviceId++, this); + } + else if (Xbox360WirelessDongle.canClaimDevice(device)) { + controller = new Xbox360WirelessDongle(device, connection, nextDeviceId++, this); + } + else { + // Unreachable + return; + } + + // Start the controller + if (!controller.start()) { + connection.close(); + return; + } + + // Add this controller to the list + controllers.add(controller); + } + } + + public static boolean isRecognizedInputDevice(UsbDevice device) { + // Determine if this VID and PID combo matches an existing input device + // and defer to the built-in controller support in that case. + for (int id : InputDevice.getDeviceIds()) { + InputDevice inputDev = InputDevice.getDevice(id); + if (inputDev == null) { + // Device was removed while looping + continue; + } + + if (inputDev.getVendorId() == device.getVendorId() && + inputDev.getProductId() == device.getProductId()) { + return true; + } + } + + return false; + } + + public static boolean kernelSupportsXboxOne() { + String kernelVersion = System.getProperty("os.version"); + LimeLog.info("Kernel Version: "+kernelVersion); + + if (kernelVersion == null) { + // We'll assume this is some newer version of Android + // that doesn't let you read the kernel version this way. + return true; + } + else if (kernelVersion.startsWith("2.") || kernelVersion.startsWith("3.")) { + // These are old kernels that definitely don't support Xbox One controllers properly + return false; + } + else if (kernelVersion.startsWith("4.4.") || kernelVersion.startsWith("4.9.")) { + // These aren't guaranteed to have backported kernel patches for proper Xbox One + // support (though some devices will). + return false; + } + else { + // The next AOSP common kernel is 4.14 which has working Xbox One controller support + return true; + } + } + + public static boolean kernelSupportsXbox360W() { + // Check if this kernel is 4.2+ to see if the xpad driver sets Xbox 360 wireless LEDs + // https://github.com/torvalds/linux/commit/75b7f05d2798ee3a1cc5bbdd54acd0e318a80396 + String kernelVersion = System.getProperty("os.version"); + if (kernelVersion != null) { + if (kernelVersion.startsWith("2.") || kernelVersion.startsWith("3.") || + kernelVersion.startsWith("4.0.") || kernelVersion.startsWith("4.1.")) { + // Even if LED devices are present, the driver won't set the initial LED state. + return false; + } + } + + // We know we have a kernel that should set Xbox 360 wireless LEDs, but we still don't + // know if CONFIG_JOYSTICK_XPAD_LEDS was enabled during the kernel build. Unfortunately + // it's not possible to detect this reliably due to Android's app sandboxing. Reading + // /proc/config.gz and enumerating /sys/class/leds are both blocked by SELinux on any + // relatively modern device. We will assume that CONFIG_JOYSTICK_XPAD_LEDS=y on these + // kernels and users can override by using the settings option to claim all devices. + return true; + } + + public static boolean shouldClaimDevice(UsbDevice device, boolean claimAllAvailable) { + LimeLog.info("UsbDevice info: "+device.toString()); + return ((!kernelSupportsXboxOne() || !isRecognizedInputDevice(device) || claimAllAvailable) && XboxOneController.canClaimDevice(device)) || + ((!isRecognizedInputDevice(device) || claimAllAvailable) && Xbox360Controller.canClaimDevice(device)) || + // We must not call isRecognizedInputDevice() because wireless controllers don't share the same product ID as the dongle + ((!kernelSupportsXbox360W() || claimAllAvailable) && Xbox360WirelessDongle.canClaimDevice(device)); + } + + @SuppressLint("UnspecifiedRegisterReceiverFlag") + private void start() { + if (started || usbManager == null) { + return; + } + + started = true; + + // Register for USB attach broadcasts and permission completions + IntentFilter filter = new IntentFilter(); + filter.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED); + filter.addAction(ACTION_USB_PERMISSION); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(receiver, filter, RECEIVER_NOT_EXPORTED); + } + else { + registerReceiver(receiver, filter); + } + + // Enumerate existing devices + for (UsbDevice dev : usbManager.getDeviceList().values()) { + if (shouldClaimDevice(dev, prefConfig.bindAllUsb)) { + // Start the process of claiming this device + handleUsbDeviceState(dev); + } + } + } + + private void stop() { + if (!started) { + return; + } + + started = false; + + // Stop the attachment receiver + unregisterReceiver(receiver); + + // Stop all controllers + while (controllers.size() > 0) { + // Stop and remove the controller + controllers.remove(0).stop(); + } + } + + @Override + public void onCreate() { + this.usbManager = (UsbManager) getSystemService(Context.USB_SERVICE); + this.prefConfig = PreferenceConfiguration.readPreferences(this); + } + + @Override + public void onDestroy() { + stop(); + + // Remove listeners + listener = null; + stateListener = null; + } + + @Override + public IBinder onBind(Intent intent) { + return binder; + } + + public interface UsbDriverStateListener { + void onUsbPermissionPromptStarting(); + void onUsbPermissionPromptCompleted(); + } +} diff --git a/app/src/main/java/com/limelight/binding/input/driver/Xbox360Controller.java b/app/src/main/java/com/limelight/binding/input/driver/Xbox360Controller.java old mode 100644 new mode 100755 index cc4b744e6f..648e043891 --- a/app/src/main/java/com/limelight/binding/input/driver/Xbox360Controller.java +++ b/app/src/main/java/com/limelight/binding/input/driver/Xbox360Controller.java @@ -1,165 +1,166 @@ -package com.limelight.binding.input.driver; - -import android.hardware.usb.UsbConstants; -import android.hardware.usb.UsbDevice; -import android.hardware.usb.UsbDeviceConnection; - -import com.limelight.LimeLog; -import com.limelight.nvstream.input.ControllerPacket; - -import java.nio.ByteBuffer; - -public class Xbox360Controller extends AbstractXboxController { - private static final int XB360_IFACE_SUBCLASS = 93; - private static final int XB360_IFACE_PROTOCOL = 1; // Wired only - - private static final int[] SUPPORTED_VENDORS = { - 0x0079, // GPD Win 2 - 0x044f, // Thrustmaster - 0x045e, // Microsoft - 0x046d, // Logitech - 0x056e, // Elecom - 0x06a3, // Saitek - 0x0738, // Mad Catz - 0x07ff, // Mad Catz - 0x0e6f, // Unknown - 0x0f0d, // Hori - 0x1038, // SteelSeries - 0x11c9, // Nacon - 0x1209, // Ardwiino - 0x12ab, // Unknown - 0x1430, // RedOctane - 0x146b, // BigBen - 0x1532, // Razer Sabertooth - 0x15e4, // Numark - 0x162e, // Joytech - 0x1689, // Razer Onza - 0x1949, // Lab126 (Amazon Luna) - 0x1bad, // Harmonix - 0x20d6, // PowerA - 0x24c6, // PowerA - 0x2f24, // GameSir - 0x2dc8, // 8BitDo - }; - - public static boolean canClaimDevice(UsbDevice device) { - for (int supportedVid : SUPPORTED_VENDORS) { - if (device.getVendorId() == supportedVid && - device.getInterfaceCount() >= 1 && - device.getInterface(0).getInterfaceClass() == UsbConstants.USB_CLASS_VENDOR_SPEC && - device.getInterface(0).getInterfaceSubclass() == XB360_IFACE_SUBCLASS && - device.getInterface(0).getInterfaceProtocol() == XB360_IFACE_PROTOCOL) { - return true; - } - } - - return false; - } - - public Xbox360Controller(UsbDevice device, UsbDeviceConnection connection, int deviceId, UsbDriverListener listener) { - super(device, connection, deviceId, listener); - } - - private int unsignByte(byte b) { - if (b < 0) { - return b + 256; - } - else { - return b; - } - } - - @Override - protected boolean handleRead(ByteBuffer buffer) { - if (buffer.remaining() < 14) { - LimeLog.severe("Read too small: "+buffer.remaining()); - return false; - } - - // Skip first short - buffer.position(buffer.position() + 2); - - // DPAD - byte b = buffer.get(); - setButtonFlag(ControllerPacket.LEFT_FLAG, b & 0x04); - setButtonFlag(ControllerPacket.RIGHT_FLAG, b & 0x08); - setButtonFlag(ControllerPacket.UP_FLAG, b & 0x01); - setButtonFlag(ControllerPacket.DOWN_FLAG, b & 0x02); - - // Start/Select - setButtonFlag(ControllerPacket.PLAY_FLAG, b & 0x10); - setButtonFlag(ControllerPacket.BACK_FLAG, b & 0x20); - - // LS/RS - setButtonFlag(ControllerPacket.LS_CLK_FLAG, b & 0x40); - setButtonFlag(ControllerPacket.RS_CLK_FLAG, b & 0x80); - - // ABXY buttons - b = buffer.get(); - setButtonFlag(ControllerPacket.A_FLAG, b & 0x10); - setButtonFlag(ControllerPacket.B_FLAG, b & 0x20); - setButtonFlag(ControllerPacket.X_FLAG, b & 0x40); - setButtonFlag(ControllerPacket.Y_FLAG, b & 0x80); - - // LB/RB - setButtonFlag(ControllerPacket.LB_FLAG, b & 0x01); - setButtonFlag(ControllerPacket.RB_FLAG, b & 0x02); - - // Xbox button - setButtonFlag(ControllerPacket.SPECIAL_BUTTON_FLAG, b & 0x04); - - // Triggers - leftTrigger = unsignByte(buffer.get()) / 255.0f; - rightTrigger = unsignByte(buffer.get()) / 255.0f; - - // Left stick - leftStickX = buffer.getShort() / 32767.0f; - leftStickY = ~buffer.getShort() / 32767.0f; - - // Right stick - rightStickX = buffer.getShort() / 32767.0f; - rightStickY = ~buffer.getShort() / 32767.0f; - - // Return true to send input - return true; - } - - private boolean sendLedCommand(byte command) { - byte[] commandBuffer = {0x01, 0x03, command}; - - int res = connection.bulkTransfer(outEndpt, commandBuffer, commandBuffer.length, 3000); - if (res != commandBuffer.length) { - LimeLog.warning("LED set transfer failed: "+res); - return false; - } - - return true; - } - - @Override - protected boolean doInit() { - // Turn the LED on corresponding to our device ID - sendLedCommand((byte)(2 + (getControllerId() % 4))); - - // No need to fail init if the LED command fails - return true; - } - - @Override - public void rumble(short lowFreqMotor, short highFreqMotor) { - byte[] data = { - 0x00, 0x08, 0x00, - (byte)(lowFreqMotor >> 8), (byte)(highFreqMotor >> 8), - 0x00, 0x00, 0x00 - }; - int res = connection.bulkTransfer(outEndpt, data, data.length, 100); - if (res != data.length) { - LimeLog.warning("Rumble transfer failed: "+res); - } - } - - @Override - public void rumbleTriggers(short leftTrigger, short rightTrigger) { - // Trigger motors not present on Xbox 360 controllers - } -} +package com.limelight.binding.input.driver; + +import android.hardware.usb.UsbConstants; +import android.hardware.usb.UsbDevice; +import android.hardware.usb.UsbDeviceConnection; + +import com.limelight.LimeLog; +import com.limelight.nvstream.input.ControllerPacket; + +import java.nio.ByteBuffer; + +public class Xbox360Controller extends AbstractXboxController { + private static final int XB360_IFACE_SUBCLASS = 93; + private static final int XB360_IFACE_PROTOCOL = 1; // Wired only + + private static final int[] SUPPORTED_VENDORS = { + 0x0079, // GPD Win 2 + 0x044f, // Thrustmaster + 0x045e, // Microsoft + 0x046d, // Logitech + 0x056e, // Elecom + 0x06a3, // Saitek + 0x0738, // Mad Catz + 0x07ff, // Mad Catz + 0x0e6f, // Unknown + 0x0f0d, // Hori + 0x1038, // SteelSeries + 0x11c9, // Nacon + 0x1209, // Ardwiino + 0x12ab, // Unknown + 0x1430, // RedOctane + 0x146b, // BigBen + 0x1532, // Razer Sabertooth + 0x15e4, // Numark + 0x162e, // Joytech + 0x1689, // Razer Onza + 0x1949, // Lab126 (Amazon Luna) + 0x1bad, // Harmonix + 0x20d6, // PowerA + 0x24c6, // PowerA + 0x2f24, // GameSir + 0x2dc8, // 8BitDo + 0x413d, // 小鸡启明星 + }; + + public static boolean canClaimDevice(UsbDevice device) { + for (int supportedVid : SUPPORTED_VENDORS) { + if (device.getVendorId() == supportedVid && + device.getInterfaceCount() >= 1 && + device.getInterface(0).getInterfaceClass() == UsbConstants.USB_CLASS_VENDOR_SPEC && + device.getInterface(0).getInterfaceSubclass() == XB360_IFACE_SUBCLASS && + device.getInterface(0).getInterfaceProtocol() == XB360_IFACE_PROTOCOL) { + return true; + } + } + + return false; + } + + public Xbox360Controller(UsbDevice device, UsbDeviceConnection connection, int deviceId, UsbDriverListener listener) { + super(device, connection, deviceId, listener); + } + + private int unsignByte(byte b) { + if (b < 0) { + return b + 256; + } + else { + return b; + } + } + + @Override + protected boolean handleRead(ByteBuffer buffer) { + if (buffer.remaining() < 14) { + LimeLog.severe("Read too small: "+buffer.remaining()); + return false; + } + + // Skip first short + buffer.position(buffer.position() + 2); + + // DPAD + byte b = buffer.get(); + setButtonFlag(ControllerPacket.LEFT_FLAG, b & 0x04); + setButtonFlag(ControllerPacket.RIGHT_FLAG, b & 0x08); + setButtonFlag(ControllerPacket.UP_FLAG, b & 0x01); + setButtonFlag(ControllerPacket.DOWN_FLAG, b & 0x02); + + // Start/Select + setButtonFlag(ControllerPacket.PLAY_FLAG, b & 0x10); + setButtonFlag(ControllerPacket.BACK_FLAG, b & 0x20); + + // LS/RS + setButtonFlag(ControllerPacket.LS_CLK_FLAG, b & 0x40); + setButtonFlag(ControllerPacket.RS_CLK_FLAG, b & 0x80); + + // ABXY buttons + b = buffer.get(); + setButtonFlag(ControllerPacket.A_FLAG, b & 0x10); + setButtonFlag(ControllerPacket.B_FLAG, b & 0x20); + setButtonFlag(ControllerPacket.X_FLAG, b & 0x40); + setButtonFlag(ControllerPacket.Y_FLAG, b & 0x80); + + // LB/RB + setButtonFlag(ControllerPacket.LB_FLAG, b & 0x01); + setButtonFlag(ControllerPacket.RB_FLAG, b & 0x02); + + // Xbox button + setButtonFlag(ControllerPacket.SPECIAL_BUTTON_FLAG, b & 0x04); + + // Triggers + leftTrigger = unsignByte(buffer.get()) / 255.0f; + rightTrigger = unsignByte(buffer.get()) / 255.0f; + + // Left stick + leftStickX = buffer.getShort() / 32767.0f; + leftStickY = ~buffer.getShort() / 32767.0f; + + // Right stick + rightStickX = buffer.getShort() / 32767.0f; + rightStickY = ~buffer.getShort() / 32767.0f; + + // Return true to send input + return true; + } + + private boolean sendLedCommand(byte command) { + byte[] commandBuffer = {0x01, 0x03, command}; + + int res = connection.bulkTransfer(outEndpt, commandBuffer, commandBuffer.length, 3000); + if (res != commandBuffer.length) { + LimeLog.warning("LED set transfer failed: "+res); + return false; + } + + return true; + } + + @Override + protected boolean doInit() { + // Turn the LED on corresponding to our device ID + sendLedCommand((byte)(2 + (getControllerId() % 4))); + + // No need to fail init if the LED command fails + return true; + } + + @Override + public void rumble(short lowFreqMotor, short highFreqMotor) { + byte[] data = { + 0x00, 0x08, 0x00, + (byte)(lowFreqMotor >> 8), (byte)(highFreqMotor >> 8), + 0x00, 0x00, 0x00 + }; + int res = connection.bulkTransfer(outEndpt, data, data.length, 100); + if (res != data.length) { + LimeLog.warning("Rumble transfer failed: "+res); + } + } + + @Override + public void rumbleTriggers(short leftTrigger, short rightTrigger) { + // Trigger motors not present on Xbox 360 controllers + } +} diff --git a/app/src/main/java/com/limelight/binding/input/driver/Xbox360WirelessDongle.java b/app/src/main/java/com/limelight/binding/input/driver/Xbox360WirelessDongle.java old mode 100644 new mode 100755 index 0aa311a613..91ae2be1fc --- a/app/src/main/java/com/limelight/binding/input/driver/Xbox360WirelessDongle.java +++ b/app/src/main/java/com/limelight/binding/input/driver/Xbox360WirelessDongle.java @@ -1,148 +1,148 @@ -package com.limelight.binding.input.driver; - -import android.hardware.usb.UsbConstants; -import android.hardware.usb.UsbDevice; -import android.hardware.usb.UsbDeviceConnection; -import android.hardware.usb.UsbEndpoint; -import android.hardware.usb.UsbInterface; -import android.os.Build; -import android.view.InputDevice; - -import com.limelight.LimeLog; - -import java.nio.ByteBuffer; - -public class Xbox360WirelessDongle extends AbstractController { - private UsbDevice device; - private UsbDeviceConnection connection; - - private static final int XB360W_IFACE_SUBCLASS = 93; - private static final int XB360W_IFACE_PROTOCOL = 129; // Wireless only - - private static final int[] SUPPORTED_VENDORS = { - 0x045e, // Microsoft - }; - - public static boolean canClaimDevice(UsbDevice device) { - for (int supportedVid : SUPPORTED_VENDORS) { - if (device.getVendorId() == supportedVid && - device.getInterfaceCount() >= 1 && - device.getInterface(0).getInterfaceClass() == UsbConstants.USB_CLASS_VENDOR_SPEC && - device.getInterface(0).getInterfaceSubclass() == XB360W_IFACE_SUBCLASS && - device.getInterface(0).getInterfaceProtocol() == XB360W_IFACE_PROTOCOL) { - return true; - } - } - - return false; - } - - public Xbox360WirelessDongle(UsbDevice device, UsbDeviceConnection connection, int deviceId, UsbDriverListener listener) { - super(deviceId, listener, device.getVendorId(), device.getProductId()); - this.device = device; - this.connection = connection; - } - - private void sendLedCommandToEndpoint(UsbEndpoint endpoint, int controllerIndex) { - byte[] commandBuffer = { - 0x00, - 0x00, - 0x08, - (byte) (0x40 + (2 + (controllerIndex % 4))), - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00}; - - int res = connection.bulkTransfer(endpoint, commandBuffer, commandBuffer.length, 3000); - if (res != commandBuffer.length) { - LimeLog.warning("LED set transfer failed: "+res); - } - } - - private void sendLedCommandToInterface(UsbInterface iface, int controllerIndex) { - // Claim this interface to kick xpad off it (temporarily) - if (!connection.claimInterface(iface, true)) { - LimeLog.warning("Failed to claim interface: "+iface.getId()); - return; - } - - // Find the out endpoint for this interface - for (int i = 0; i < iface.getEndpointCount(); i++) { - UsbEndpoint endpt = iface.getEndpoint(i); - if (endpt.getDirection() == UsbConstants.USB_DIR_OUT) { - // Send the LED command - sendLedCommandToEndpoint(endpt, controllerIndex); - break; - } - } - - // Release the interface to allow xpad to take over again - connection.releaseInterface(iface); - } - - @Override - public boolean start() { - int controllerIndex = 0; - - // On Android, there is a controller number associated with input devices. - // We can use this to approximate the likely controller number. This won't - // be completely accurate because there's no guarantee the order of interfaces - // matches the order that devices were enumerated by xpad, but it's probably - // better than nothing. - for (int id : InputDevice.getDeviceIds()) { - InputDevice inputDev = InputDevice.getDevice(id); - if (inputDev == null) { - // Device was removed while looping - continue; - } - - // Newer xpad versions use a special product ID (0x02a1) for controllers - // rather than copying the product ID of the dongle itself. - if (inputDev.getVendorId() == device.getVendorId() && - (inputDev.getProductId() == device.getProductId() || - inputDev.getProductId() == 0x02a1) && - inputDev.getControllerNumber() > 0) { - controllerIndex = inputDev.getControllerNumber() - 1; - break; - } - } - - // Send LED commands on the out endpoint of each interface. There is one interface - // corresponding to each possible attached controller. - for (int i = 0; i < device.getInterfaceCount(); i++) { - UsbInterface iface = device.getInterface(i); - - // Skip the non-input interfaces - if (iface.getInterfaceClass() != UsbConstants.USB_CLASS_VENDOR_SPEC || - iface.getInterfaceSubclass() != XB360W_IFACE_SUBCLASS || - iface.getInterfaceProtocol() != XB360W_IFACE_PROTOCOL) { - continue; - } - - sendLedCommandToInterface(iface, controllerIndex++); - } - - // "Fail" to give control back to the kernel driver - return false; - } - - @Override - public void stop() { - // Nothing to do - } - - @Override - public void rumble(short lowFreqMotor, short highFreqMotor) { - // Unreachable. - } - - @Override - public void rumbleTriggers(short leftTrigger, short rightTrigger) { - // Unreachable. - } -} +package com.limelight.binding.input.driver; + +import android.hardware.usb.UsbConstants; +import android.hardware.usb.UsbDevice; +import android.hardware.usb.UsbDeviceConnection; +import android.hardware.usb.UsbEndpoint; +import android.hardware.usb.UsbInterface; +import android.os.Build; +import android.view.InputDevice; + +import com.limelight.LimeLog; + +import java.nio.ByteBuffer; + +public class Xbox360WirelessDongle extends AbstractController { + private UsbDevice device; + private UsbDeviceConnection connection; + + private static final int XB360W_IFACE_SUBCLASS = 93; + private static final int XB360W_IFACE_PROTOCOL = 129; // Wireless only + + private static final int[] SUPPORTED_VENDORS = { + 0x045e, // Microsoft + }; + + public static boolean canClaimDevice(UsbDevice device) { + for (int supportedVid : SUPPORTED_VENDORS) { + if (device.getVendorId() == supportedVid && + device.getInterfaceCount() >= 1 && + device.getInterface(0).getInterfaceClass() == UsbConstants.USB_CLASS_VENDOR_SPEC && + device.getInterface(0).getInterfaceSubclass() == XB360W_IFACE_SUBCLASS && + device.getInterface(0).getInterfaceProtocol() == XB360W_IFACE_PROTOCOL) { + return true; + } + } + + return false; + } + + public Xbox360WirelessDongle(UsbDevice device, UsbDeviceConnection connection, int deviceId, UsbDriverListener listener) { + super(deviceId, listener, device.getVendorId(), device.getProductId()); + this.device = device; + this.connection = connection; + } + + private void sendLedCommandToEndpoint(UsbEndpoint endpoint, int controllerIndex) { + byte[] commandBuffer = { + 0x00, + 0x00, + 0x08, + (byte) (0x40 + (2 + (controllerIndex % 4))), + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00}; + + int res = connection.bulkTransfer(endpoint, commandBuffer, commandBuffer.length, 3000); + if (res != commandBuffer.length) { + LimeLog.warning("LED set transfer failed: "+res); + } + } + + private void sendLedCommandToInterface(UsbInterface iface, int controllerIndex) { + // Claim this interface to kick xpad off it (temporarily) + if (!connection.claimInterface(iface, true)) { + LimeLog.warning("Failed to claim interface: "+iface.getId()); + return; + } + + // Find the out endpoint for this interface + for (int i = 0; i < iface.getEndpointCount(); i++) { + UsbEndpoint endpt = iface.getEndpoint(i); + if (endpt.getDirection() == UsbConstants.USB_DIR_OUT) { + // Send the LED command + sendLedCommandToEndpoint(endpt, controllerIndex); + break; + } + } + + // Release the interface to allow xpad to take over again + connection.releaseInterface(iface); + } + + @Override + public boolean start() { + int controllerIndex = 0; + + // On Android, there is a controller number associated with input devices. + // We can use this to approximate the likely controller number. This won't + // be completely accurate because there's no guarantee the order of interfaces + // matches the order that devices were enumerated by xpad, but it's probably + // better than nothing. + for (int id : InputDevice.getDeviceIds()) { + InputDevice inputDev = InputDevice.getDevice(id); + if (inputDev == null) { + // Device was removed while looping + continue; + } + + // Newer xpad versions use a special product ID (0x02a1) for controllers + // rather than copying the product ID of the dongle itself. + if (inputDev.getVendorId() == device.getVendorId() && + (inputDev.getProductId() == device.getProductId() || + inputDev.getProductId() == 0x02a1) && + inputDev.getControllerNumber() > 0) { + controllerIndex = inputDev.getControllerNumber() - 1; + break; + } + } + + // Send LED commands on the out endpoint of each interface. There is one interface + // corresponding to each possible attached controller. + for (int i = 0; i < device.getInterfaceCount(); i++) { + UsbInterface iface = device.getInterface(i); + + // Skip the non-input interfaces + if (iface.getInterfaceClass() != UsbConstants.USB_CLASS_VENDOR_SPEC || + iface.getInterfaceSubclass() != XB360W_IFACE_SUBCLASS || + iface.getInterfaceProtocol() != XB360W_IFACE_PROTOCOL) { + continue; + } + + sendLedCommandToInterface(iface, controllerIndex++); + } + + // "Fail" to give control back to the kernel driver + return false; + } + + @Override + public void stop() { + // Nothing to do + } + + @Override + public void rumble(short lowFreqMotor, short highFreqMotor) { + // Unreachable. + } + + @Override + public void rumbleTriggers(short leftTrigger, short rightTrigger) { + // Unreachable. + } +} diff --git a/app/src/main/java/com/limelight/binding/input/driver/XboxOneController.java b/app/src/main/java/com/limelight/binding/input/driver/XboxOneController.java old mode 100644 new mode 100755 index b84f2cc749..aae6b0c893 --- a/app/src/main/java/com/limelight/binding/input/driver/XboxOneController.java +++ b/app/src/main/java/com/limelight/binding/input/driver/XboxOneController.java @@ -1,226 +1,228 @@ -package com.limelight.binding.input.driver; - -import android.hardware.usb.UsbConstants; -import android.hardware.usb.UsbDevice; -import android.hardware.usb.UsbDeviceConnection; - -import com.limelight.LimeLog; -import com.limelight.nvstream.input.ControllerPacket; -import com.limelight.nvstream.jni.MoonBridge; - -import java.nio.ByteBuffer; -import java.util.Arrays; - -public class XboxOneController extends AbstractXboxController { - - private static final int XB1_IFACE_SUBCLASS = 71; - private static final int XB1_IFACE_PROTOCOL = 208; - - private static final int[] SUPPORTED_VENDORS = { - 0x045e, // Microsoft - 0x0738, // Mad Catz - 0x0e6f, // Unknown - 0x0f0d, // Hori - 0x1532, // Razer Wildcat - 0x20d6, // PowerA - 0x24c6, // PowerA - 0x2e24, // Hyperkin - }; - - private static final byte[] FW2015_INIT = {0x05, 0x20, 0x00, 0x01, 0x00}; - private static final byte[] ONE_S_INIT = {0x05, 0x20, 0x00, 0x0f, 0x06}; - private static final byte[] HORI_INIT = {0x01, 0x20, 0x00, 0x09, 0x00, 0x04, 0x20, 0x3a, - 0x00, 0x00, 0x00, (byte)0x80, 0x00}; - private static final byte[] PDP_INIT1 = {0x0a, 0x20, 0x00, 0x03, 0x00, 0x01, 0x14}; - private static final byte[] PDP_INIT2 = {0x06, 0x20, 0x00, 0x02, 0x01, 0x00}; - private static final byte[] RUMBLE_INIT1 = {0x09, 0x00, 0x00, 0x09, 0x00, 0x0F, 0x00, 0x00, - 0x1D, 0x1D, (byte)0xFF, 0x00, 0x00}; - private static final byte[] RUMBLE_INIT2 = {0x09, 0x00, 0x00, 0x09, 0x00, 0x0F, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00}; - - private static InitPacket[] INIT_PKTS = { - new InitPacket(0x0e6f, 0x0165, HORI_INIT), - new InitPacket(0x0f0d, 0x0067, HORI_INIT), - new InitPacket(0x0000, 0x0000, FW2015_INIT), - new InitPacket(0x045e, 0x02ea, ONE_S_INIT), - new InitPacket(0x045e, 0x0b00, ONE_S_INIT), - new InitPacket(0x0e6f, 0x0000, PDP_INIT1), - new InitPacket(0x0e6f, 0x0000, PDP_INIT2), - new InitPacket(0x24c6, 0x541a, RUMBLE_INIT1), - new InitPacket(0x24c6, 0x542a, RUMBLE_INIT1), - new InitPacket(0x24c6, 0x543a, RUMBLE_INIT1), - new InitPacket(0x24c6, 0x541a, RUMBLE_INIT2), - new InitPacket(0x24c6, 0x542a, RUMBLE_INIT2), - new InitPacket(0x24c6, 0x543a, RUMBLE_INIT2), - }; - - private byte seqNum = 0; - private short lowFreqMotor = 0; - private short highFreqMotor = 0; - private short leftTriggerMotor = 0; - private short rightTriggerMotor = 0; - - public XboxOneController(UsbDevice device, UsbDeviceConnection connection, int deviceId, UsbDriverListener listener) { - super(device, connection, deviceId, listener); - capabilities |= MoonBridge.LI_CCAP_TRIGGER_RUMBLE; - } - - private void processButtons(ByteBuffer buffer) { - byte b = buffer.get(); - - setButtonFlag(ControllerPacket.PLAY_FLAG, b & 0x04); - setButtonFlag(ControllerPacket.BACK_FLAG, b & 0x08); - - setButtonFlag(ControllerPacket.A_FLAG, b & 0x10); - setButtonFlag(ControllerPacket.B_FLAG, b & 0x20); - setButtonFlag(ControllerPacket.X_FLAG, b & 0x40); - setButtonFlag(ControllerPacket.Y_FLAG, b & 0x80); - - b = buffer.get(); - setButtonFlag(ControllerPacket.LEFT_FLAG, b & 0x04); - setButtonFlag(ControllerPacket.RIGHT_FLAG, b & 0x08); - setButtonFlag(ControllerPacket.UP_FLAG, b & 0x01); - setButtonFlag(ControllerPacket.DOWN_FLAG, b & 0x02); - - setButtonFlag(ControllerPacket.LB_FLAG, b & 0x10); - setButtonFlag(ControllerPacket.RB_FLAG, b & 0x20); - - setButtonFlag(ControllerPacket.LS_CLK_FLAG, b & 0x40); - setButtonFlag(ControllerPacket.RS_CLK_FLAG, b & 0x80); - - leftTrigger = buffer.getShort() / 1023.0f; - rightTrigger = buffer.getShort() / 1023.0f; - - leftStickX = buffer.getShort() / 32767.0f; - leftStickY = ~buffer.getShort() / 32767.0f; - - rightStickX = buffer.getShort() / 32767.0f; - rightStickY = ~buffer.getShort() / 32767.0f; - } - - private void ackModeReport(byte seqNum) { - byte[] payload = {0x01, 0x20, seqNum, 0x09, 0x00, 0x07, 0x20, 0x02, - 0x00, 0x00, 0x00, 0x00, 0x00}; - connection.bulkTransfer(outEndpt, payload, payload.length, 3000); - } - - @Override - protected boolean handleRead(ByteBuffer buffer) { - switch (buffer.get()) - { - case 0x20: - if (buffer.remaining() < 17) { - LimeLog.severe("XBone button/axis read too small: "+buffer.remaining()); - return false; - } - - buffer.position(buffer.position()+3); - processButtons(buffer); - return true; - - case 0x07: - if (buffer.remaining() < 4) { - LimeLog.severe("XBone mode read too small: "+buffer.remaining()); - return false; - } - - // The Xbox One S controller needs acks for mode reports otherwise - // it retransmits them forever. - if (buffer.get() == 0x30) { - ackModeReport(buffer.get()); - buffer.position(buffer.position() + 1); - } - else { - buffer.position(buffer.position() + 2); - } - setButtonFlag(ControllerPacket.SPECIAL_BUTTON_FLAG, buffer.get() & 0x01); - return true; - } - - return false; - } - - public static boolean canClaimDevice(UsbDevice device) { - for (int supportedVid : SUPPORTED_VENDORS) { - if (device.getVendorId() == supportedVid && - device.getInterfaceCount() >= 1 && - device.getInterface(0).getInterfaceClass() == UsbConstants.USB_CLASS_VENDOR_SPEC && - device.getInterface(0).getInterfaceSubclass() == XB1_IFACE_SUBCLASS && - device.getInterface(0).getInterfaceProtocol() == XB1_IFACE_PROTOCOL) { - return true; - } - } - - return false; - } - - @Override - protected boolean doInit() { - // Send all applicable init packets - for (InitPacket pkt : INIT_PKTS) { - if (pkt.vendorId != 0 && device.getVendorId() != pkt.vendorId) { - continue; - } - - if (pkt.productId != 0 && device.getProductId() != pkt.productId) { - continue; - } - - byte[] data = Arrays.copyOf(pkt.data, pkt.data.length); - - // Populate sequence number - data[2] = seqNum++; - - // Send the initialization packet - int res = connection.bulkTransfer(outEndpt, data, data.length, 3000); - if (res != data.length) { - LimeLog.warning("Initialization transfer failed: "+res); - return false; - } - } - - return true; - } - - private void sendRumblePacket() { - byte[] data = { - 0x09, 0x00, seqNum++, 0x09, 0x00, - 0x0F, - (byte)(leftTriggerMotor >> 9), - (byte)(rightTriggerMotor >> 9), - (byte)(lowFreqMotor >> 9), - (byte)(highFreqMotor >> 9), - (byte)0xFF, 0x00, (byte)0xFF - }; - int res = connection.bulkTransfer(outEndpt, data, data.length, 100); - if (res != data.length) { - LimeLog.warning("Rumble transfer failed: "+res); - } - } - - @Override - public void rumble(short lowFreqMotor, short highFreqMotor) { - this.lowFreqMotor = lowFreqMotor; - this.highFreqMotor = highFreqMotor; - sendRumblePacket(); - } - - @Override - public void rumbleTriggers(short leftTrigger, short rightTrigger) { - this.leftTriggerMotor = leftTrigger; - this.rightTriggerMotor = rightTrigger; - sendRumblePacket(); - } - - private static class InitPacket { - final int vendorId; - final int productId; - final byte[] data; - - InitPacket(int vendorId, int productId, byte[] data) { - this.vendorId = vendorId; - this.productId = productId; - this.data = data; - } - } -} +package com.limelight.binding.input.driver; + +import android.hardware.usb.UsbConstants; +import android.hardware.usb.UsbDevice; +import android.hardware.usb.UsbDeviceConnection; + +import com.limelight.LimeLog; +import com.limelight.nvstream.input.ControllerPacket; +import com.limelight.nvstream.jni.MoonBridge; + +import java.nio.ByteBuffer; +import java.util.Arrays; + +public class XboxOneController extends AbstractXboxController { + + private static final int XB1_IFACE_SUBCLASS = 71; + private static final int XB1_IFACE_PROTOCOL = 208; + + private static final int[] SUPPORTED_VENDORS = { + 0x045e, // Microsoft + 0x0738, // Mad Catz + 0x0e6f, // Unknown + 0x0f0d, // Hori + 0x1532, // Razer Wildcat + 0x20d6, // PowerA + 0x24c6, // PowerA + 0x2e24, // Hyperkin + }; + + private static final byte[] FW2015_INIT = {0x05, 0x20, 0x00, 0x01, 0x00}; + private static final byte[] ONE_S_INIT = {0x05, 0x20, 0x00, 0x0f, 0x06}; + private static final byte[] HORI_INIT = {0x01, 0x20, 0x00, 0x09, 0x00, 0x04, 0x20, 0x3a, + 0x00, 0x00, 0x00, (byte)0x80, 0x00}; + private static final byte[] PDP_INIT1 = {0x0a, 0x20, 0x00, 0x03, 0x00, 0x01, 0x14}; + private static final byte[] PDP_INIT2 = {0x06, 0x20, 0x00, 0x02, 0x01, 0x00}; + private static final byte[] RUMBLE_INIT1 = {0x09, 0x00, 0x00, 0x09, 0x00, 0x0F, 0x00, 0x00, + 0x1D, 0x1D, (byte)0xFF, 0x00, 0x00}; + private static final byte[] RUMBLE_INIT2 = {0x09, 0x00, 0x00, 0x09, 0x00, 0x0F, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00}; + + private static InitPacket[] INIT_PKTS = { + new InitPacket(0x0e6f, 0x0165, HORI_INIT), + new InitPacket(0x0f0d, 0x0067, HORI_INIT), + new InitPacket(0x0000, 0x0000, FW2015_INIT), + new InitPacket(0x045e, 0x02ea, ONE_S_INIT),//Xbox Wireless Controller, HWID Model 1708 + new InitPacket(0x045e, 0x0b00, ONE_S_INIT), + new InitPacket(0x0e6f, 0x0000, PDP_INIT1), + new InitPacket(0x0e6f, 0x0000, PDP_INIT2), + new InitPacket(0x24c6, 0x541a, RUMBLE_INIT1), + new InitPacket(0x24c6, 0x542a, RUMBLE_INIT1), + new InitPacket(0x24c6, 0x543a, RUMBLE_INIT1), + new InitPacket(0x24c6, 0x541a, RUMBLE_INIT2), + new InitPacket(0x24c6, 0x542a, RUMBLE_INIT2), + new InitPacket(0x24c6, 0x543a, RUMBLE_INIT2), + new InitPacket(0x045e, 0x0b12, ONE_S_INIT),//Xbox Wireless Controller, HWID Model 1914 + new InitPacket(0x045e, 0x02fe, ONE_S_INIT),//Xbox Wireless Controller, HWID Model 1914 + }; + + private byte seqNum = 0; + private short lowFreqMotor = 0; + private short highFreqMotor = 0; + private short leftTriggerMotor = 0; + private short rightTriggerMotor = 0; + + public XboxOneController(UsbDevice device, UsbDeviceConnection connection, int deviceId, UsbDriverListener listener) { + super(device, connection, deviceId, listener); + capabilities |= MoonBridge.LI_CCAP_TRIGGER_RUMBLE; + } + + private void processButtons(ByteBuffer buffer) { + byte b = buffer.get(); + + setButtonFlag(ControllerPacket.PLAY_FLAG, b & 0x04); + setButtonFlag(ControllerPacket.BACK_FLAG, b & 0x08); + + setButtonFlag(ControllerPacket.A_FLAG, b & 0x10); + setButtonFlag(ControllerPacket.B_FLAG, b & 0x20); + setButtonFlag(ControllerPacket.X_FLAG, b & 0x40); + setButtonFlag(ControllerPacket.Y_FLAG, b & 0x80); + + b = buffer.get(); + setButtonFlag(ControllerPacket.LEFT_FLAG, b & 0x04); + setButtonFlag(ControllerPacket.RIGHT_FLAG, b & 0x08); + setButtonFlag(ControllerPacket.UP_FLAG, b & 0x01); + setButtonFlag(ControllerPacket.DOWN_FLAG, b & 0x02); + + setButtonFlag(ControllerPacket.LB_FLAG, b & 0x10); + setButtonFlag(ControllerPacket.RB_FLAG, b & 0x20); + + setButtonFlag(ControllerPacket.LS_CLK_FLAG, b & 0x40); + setButtonFlag(ControllerPacket.RS_CLK_FLAG, b & 0x80); + + leftTrigger = buffer.getShort() / 1023.0f; + rightTrigger = buffer.getShort() / 1023.0f; + + leftStickX = buffer.getShort() / 32767.0f; + leftStickY = ~buffer.getShort() / 32767.0f; + + rightStickX = buffer.getShort() / 32767.0f; + rightStickY = ~buffer.getShort() / 32767.0f; + } + + private void ackModeReport(byte seqNum) { + byte[] payload = {0x01, 0x20, seqNum, 0x09, 0x00, 0x07, 0x20, 0x02, + 0x00, 0x00, 0x00, 0x00, 0x00}; + connection.bulkTransfer(outEndpt, payload, payload.length, 3000); + } + + @Override + protected boolean handleRead(ByteBuffer buffer) { + switch (buffer.get()) + { + case 0x20: + if (buffer.remaining() < 17) { + LimeLog.severe("XBone button/axis read too small: "+buffer.remaining()); + return false; + } + + buffer.position(buffer.position()+3); + processButtons(buffer); + return true; + + case 0x07: + if (buffer.remaining() < 4) { + LimeLog.severe("XBone mode read too small: "+buffer.remaining()); + return false; + } + + // The Xbox One S controller needs acks for mode reports otherwise + // it retransmits them forever. + if (buffer.get() == 0x30) { + ackModeReport(buffer.get()); + buffer.position(buffer.position() + 1); + } + else { + buffer.position(buffer.position() + 2); + } + setButtonFlag(ControllerPacket.SPECIAL_BUTTON_FLAG, buffer.get() & 0x01); + return true; + } + + return false; + } + + public static boolean canClaimDevice(UsbDevice device) { + for (int supportedVid : SUPPORTED_VENDORS) { + if (device.getVendorId() == supportedVid && + device.getInterfaceCount() >= 1 && + device.getInterface(0).getInterfaceClass() == UsbConstants.USB_CLASS_VENDOR_SPEC && + device.getInterface(0).getInterfaceSubclass() == XB1_IFACE_SUBCLASS && + device.getInterface(0).getInterfaceProtocol() == XB1_IFACE_PROTOCOL) { + return true; + } + } + + return false; + } + + @Override + protected boolean doInit() { + // Send all applicable init packets + for (InitPacket pkt : INIT_PKTS) { + if (pkt.vendorId != 0 && device.getVendorId() != pkt.vendorId) { + continue; + } + + if (pkt.productId != 0 && device.getProductId() != pkt.productId) { + continue; + } + + byte[] data = Arrays.copyOf(pkt.data, pkt.data.length); + + // Populate sequence number + data[2] = seqNum++; + + // Send the initialization packet + int res = connection.bulkTransfer(outEndpt, data, data.length, 3000); + if (res != data.length) { + LimeLog.warning("Initialization transfer failed: "+res); + return false; + } + } + + return true; + } + + private void sendRumblePacket() { + byte[] data = { + 0x09, 0x00, seqNum++, 0x09, 0x00, + 0x0F, + (byte)(leftTriggerMotor >> 9), + (byte)(rightTriggerMotor >> 9), + (byte)(lowFreqMotor >> 9), + (byte)(highFreqMotor >> 9), + (byte)0xFF, 0x00, (byte)0xFF + }; + int res = connection.bulkTransfer(outEndpt, data, data.length, 100); + if (res != data.length) { + LimeLog.warning("Rumble transfer failed: "+res); + } + } + + @Override + public void rumble(short lowFreqMotor, short highFreqMotor) { + this.lowFreqMotor = lowFreqMotor; + this.highFreqMotor = highFreqMotor; + sendRumblePacket(); + } + + @Override + public void rumbleTriggers(short leftTrigger, short rightTrigger) { + this.leftTriggerMotor = leftTrigger; + this.rightTriggerMotor = rightTrigger; + sendRumblePacket(); + } + + private static class InitPacket { + final int vendorId; + final int productId; + final byte[] data; + + InitPacket(int vendorId, int productId, byte[] data) { + this.vendorId = vendorId; + this.productId = productId; + this.data = data; + } + } +} diff --git a/app/src/main/java/com/limelight/binding/input/evdev/EvdevCaptureProviderShim.java b/app/src/main/java/com/limelight/binding/input/evdev/EvdevCaptureProviderShim.java old mode 100644 new mode 100755 index 5268cf9920..b2691e8c3f --- a/app/src/main/java/com/limelight/binding/input/evdev/EvdevCaptureProviderShim.java +++ b/app/src/main/java/com/limelight/binding/input/evdev/EvdevCaptureProviderShim.java @@ -1,24 +1,24 @@ -package com.limelight.binding.input.evdev; - - -import android.app.Activity; - -import com.limelight.BuildConfig; -import com.limelight.binding.input.capture.InputCaptureProvider; - -public class EvdevCaptureProviderShim { - public static boolean isCaptureProviderSupported() { - return BuildConfig.ROOT_BUILD; - } - - // We need to construct our capture provider using reflection because it isn't included in non-root builds - public static InputCaptureProvider createEvdevCaptureProvider(Activity activity, EvdevListener listener) { - try { - Class providerClass = Class.forName("com.limelight.binding.input.evdev.EvdevCaptureProvider"); - return (InputCaptureProvider) providerClass.getConstructors()[0].newInstance(activity, listener); - } catch (Exception e) { - e.printStackTrace(); - throw new RuntimeException(e); - } - } -} +package com.limelight.binding.input.evdev; + + +import android.app.Activity; + +import com.limelight.BuildConfig; +import com.limelight.binding.input.capture.InputCaptureProvider; + +public class EvdevCaptureProviderShim { + public static boolean isCaptureProviderSupported() { + return BuildConfig.ROOT_BUILD; + } + + // We need to construct our capture provider using reflection because it isn't included in non-root builds + public static InputCaptureProvider createEvdevCaptureProvider(Activity activity, EvdevListener listener) { + try { + Class providerClass = Class.forName("com.limelight.binding.input.evdev.EvdevCaptureProvider"); + return (InputCaptureProvider) providerClass.getConstructors()[0].newInstance(activity, listener); + } catch (Exception e) { + e.printStackTrace(); + throw new RuntimeException(e); + } + } +} diff --git a/app/src/main/java/com/limelight/binding/input/evdev/EvdevListener.java b/app/src/main/java/com/limelight/binding/input/evdev/EvdevListener.java old mode 100644 new mode 100755 index 6205426b61..6dfaae71f9 --- a/app/src/main/java/com/limelight/binding/input/evdev/EvdevListener.java +++ b/app/src/main/java/com/limelight/binding/input/evdev/EvdevListener.java @@ -1,15 +1,15 @@ -package com.limelight.binding.input.evdev; - -public interface EvdevListener { - int BUTTON_LEFT = 1; - int BUTTON_MIDDLE = 2; - int BUTTON_RIGHT = 3; - int BUTTON_X1 = 4; - int BUTTON_X2 = 5; - - void mouseMove(int deltaX, int deltaY); - void mouseButtonEvent(int buttonId, boolean down); - void mouseVScroll(byte amount); - void mouseHScroll(byte amount); - void keyboardEvent(boolean buttonDown, short keyCode); -} +package com.limelight.binding.input.evdev; + +public interface EvdevListener { + int BUTTON_LEFT = 1; + int BUTTON_MIDDLE = 2; + int BUTTON_RIGHT = 3; + int BUTTON_X1 = 4; + int BUTTON_X2 = 5; + + void mouseMove(int deltaX, int deltaY); + void mouseButtonEvent(int buttonId, boolean down); + void mouseVScroll(byte amount); + void mouseHScroll(byte amount); + void keyboardEvent(boolean buttonDown, short keyCode); +} diff --git a/app/src/main/java/com/limelight/binding/input/touch/AbsoluteTouchContext.java b/app/src/main/java/com/limelight/binding/input/touch/AbsoluteTouchContext.java old mode 100644 new mode 100755 index d5fb4708b1..c900cf7b4c --- a/app/src/main/java/com/limelight/binding/input/touch/AbsoluteTouchContext.java +++ b/app/src/main/java/com/limelight/binding/input/touch/AbsoluteTouchContext.java @@ -1,249 +1,249 @@ -package com.limelight.binding.input.touch; - -import android.os.Handler; -import android.os.Looper; -import android.view.View; - -import com.limelight.nvstream.NvConnection; -import com.limelight.nvstream.input.MouseButtonPacket; - -public class AbsoluteTouchContext implements TouchContext { - private int lastTouchDownX = 0; - private int lastTouchDownY = 0; - private long lastTouchDownTime = 0; - private int lastTouchUpX = 0; - private int lastTouchUpY = 0; - private long lastTouchUpTime = 0; - private int lastTouchLocationX = 0; - private int lastTouchLocationY = 0; - private boolean cancelled; - private boolean confirmedLongPress; - private boolean confirmedTap; - - private final Runnable longPressRunnable = new Runnable() { - @Override - public void run() { - // This timer should have already expired, but cancel it just in case - cancelTapDownTimer(); - - // Switch from a left click to a right click after a long press - confirmedLongPress = true; - if (confirmedTap) { - conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT); - } - conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_RIGHT); - } - }; - - private final Runnable tapDownRunnable = new Runnable() { - @Override - public void run() { - // Start our tap - tapConfirmed(); - } - }; - - private final NvConnection conn; - private final int actionIndex; - private final View targetView; - private final Handler handler; - - private final Runnable leftButtonUpRunnable = new Runnable() { - @Override - public void run() { - conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT); - } - }; - - private static final int SCROLL_SPEED_FACTOR = 3; - - private static final int LONG_PRESS_TIME_THRESHOLD = 650; - private static final int LONG_PRESS_DISTANCE_THRESHOLD = 30; - - private static final int DOUBLE_TAP_TIME_THRESHOLD = 250; - private static final int DOUBLE_TAP_DISTANCE_THRESHOLD = 60; - - private static final int TOUCH_DOWN_DEAD_ZONE_TIME_THRESHOLD = 100; - private static final int TOUCH_DOWN_DEAD_ZONE_DISTANCE_THRESHOLD = 20; - - public AbsoluteTouchContext(NvConnection conn, int actionIndex, View view) - { - this.conn = conn; - this.actionIndex = actionIndex; - this.targetView = view; - this.handler = new Handler(Looper.getMainLooper()); - } - - @Override - public int getActionIndex() - { - return actionIndex; - } - - @Override - public boolean touchDownEvent(int eventX, int eventY, long eventTime, boolean isNewFinger) - { - if (!isNewFinger) { - // We don't handle finger transitions for absolute mode - return true; - } - - lastTouchLocationX = lastTouchDownX = eventX; - lastTouchLocationY = lastTouchDownY = eventY; - lastTouchDownTime = eventTime; - cancelled = confirmedTap = confirmedLongPress = false; - - if (actionIndex == 0) { - // Start the timers - startTapDownTimer(); - startLongPressTimer(); - } - - return true; - } - - private boolean distanceExceeds(int deltaX, int deltaY, double limit) { - return Math.sqrt(Math.pow(deltaX, 2) + Math.pow(deltaY, 2)) > limit; - } - - private void updatePosition(int eventX, int eventY) { - // We may get values slightly outside our view region on ACTION_HOVER_ENTER and ACTION_HOVER_EXIT. - // Normalize these to the view size. We can't just drop them because we won't always get an event - // right at the boundary of the view, so dropping them would result in our cursor never really - // reaching the sides of the screen. - eventX = Math.min(Math.max(eventX, 0), targetView.getWidth()); - eventY = Math.min(Math.max(eventY, 0), targetView.getHeight()); - - conn.sendMousePosition((short)eventX, (short)eventY, (short)targetView.getWidth(), (short)targetView.getHeight()); - } - - @Override - public void touchUpEvent(int eventX, int eventY, long eventTime) - { - if (cancelled) { - return; - } - - if (actionIndex == 0) { - // Cancel the timers - cancelLongPressTimer(); - cancelTapDownTimer(); - - // Raise the mouse buttons that we currently have down - if (confirmedLongPress) { - conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_RIGHT); - } - else if (confirmedTap) { - conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT); - } - else { - // If we get here, this means that the tap completed within the touch down - // deadzone time. We'll need to send the touch down and up events now at the - // original touch down position. - tapConfirmed(); - - // Release the left mouse button in 100ms to allow for apps that use polling - // to detect mouse button presses. - handler.removeCallbacks(leftButtonUpRunnable); - handler.postDelayed(leftButtonUpRunnable, 100); - } - } - - lastTouchLocationX = lastTouchUpX = eventX; - lastTouchLocationY = lastTouchUpY = eventY; - lastTouchUpTime = eventTime; - } - - private void startLongPressTimer() { - cancelLongPressTimer(); - handler.postDelayed(longPressRunnable, LONG_PRESS_TIME_THRESHOLD); - } - - private void cancelLongPressTimer() { - handler.removeCallbacks(longPressRunnable); - } - - private void startTapDownTimer() { - cancelTapDownTimer(); - handler.postDelayed(tapDownRunnable, TOUCH_DOWN_DEAD_ZONE_TIME_THRESHOLD); - } - - private void cancelTapDownTimer() { - handler.removeCallbacks(tapDownRunnable); - } - - private void tapConfirmed() { - if (confirmedTap || confirmedLongPress) { - return; - } - - confirmedTap = true; - cancelTapDownTimer(); - - // Left button down at original position - if (lastTouchDownTime - lastTouchUpTime > DOUBLE_TAP_TIME_THRESHOLD || - distanceExceeds(lastTouchDownX - lastTouchUpX, lastTouchDownY - lastTouchUpY, DOUBLE_TAP_DISTANCE_THRESHOLD)) { - // Don't reposition for finger down events within the deadzone. This makes double-clicking easier. - updatePosition(lastTouchDownX, lastTouchDownY); - } - conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_LEFT); - } - - @Override - public boolean touchMoveEvent(int eventX, int eventY, long eventTime) - { - if (cancelled) { - return true; - } - - if (actionIndex == 0) { - if (distanceExceeds(eventX - lastTouchDownX, eventY - lastTouchDownY, LONG_PRESS_DISTANCE_THRESHOLD)) { - // Moved too far since touch down. Cancel the long press timer. - cancelLongPressTimer(); - } - - // Ignore motion within the deadzone period after touch down - if (confirmedTap || distanceExceeds(eventX - lastTouchDownX, eventY - lastTouchDownY, TOUCH_DOWN_DEAD_ZONE_DISTANCE_THRESHOLD)) { - tapConfirmed(); - updatePosition(eventX, eventY); - } - } - else if (actionIndex == 1) { - conn.sendMouseHighResScroll((short)((eventY - lastTouchLocationY) * SCROLL_SPEED_FACTOR)); - } - - lastTouchLocationX = eventX; - lastTouchLocationY = eventY; - - return true; - } - - @Override - public void cancelTouch() { - cancelled = true; - - // Cancel the timers - cancelLongPressTimer(); - cancelTapDownTimer(); - - // Raise the mouse buttons - if (confirmedLongPress) { - conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_RIGHT); - } - else if (confirmedTap) { - conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT); - } - } - - @Override - public boolean isCancelled() { - return cancelled; - } - - @Override - public void setPointerCount(int pointerCount) { - if (actionIndex == 0 && pointerCount > 1) { - cancelTouch(); - } - } -} +package com.limelight.binding.input.touch; + +import android.os.Handler; +import android.os.Looper; +import android.view.View; + +import com.limelight.nvstream.NvConnection; +import com.limelight.nvstream.input.MouseButtonPacket; + +public class AbsoluteTouchContext implements TouchContext { + private int lastTouchDownX = 0; + private int lastTouchDownY = 0; + private long lastTouchDownTime = 0; + private int lastTouchUpX = 0; + private int lastTouchUpY = 0; + private long lastTouchUpTime = 0; + private int lastTouchLocationX = 0; + private int lastTouchLocationY = 0; + private boolean cancelled; + private boolean confirmedLongPress; + private boolean confirmedTap; + + private final Runnable longPressRunnable = new Runnable() { + @Override + public void run() { + // This timer should have already expired, but cancel it just in case + cancelTapDownTimer(); + + // Switch from a left click to a right click after a long press + confirmedLongPress = true; + if (confirmedTap) { + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT); + } + conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_RIGHT); + } + }; + + private final Runnable tapDownRunnable = new Runnable() { + @Override + public void run() { + // Start our tap + tapConfirmed(); + } + }; + + private final NvConnection conn; + private final int actionIndex; + private final View targetView; + private final Handler handler; + + private final Runnable leftButtonUpRunnable = new Runnable() { + @Override + public void run() { + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT); + } + }; + + private static final int SCROLL_SPEED_FACTOR = 3; + + private static final int LONG_PRESS_TIME_THRESHOLD = 650; + private static final int LONG_PRESS_DISTANCE_THRESHOLD = 30; + + private static final int DOUBLE_TAP_TIME_THRESHOLD = 250; + private static final int DOUBLE_TAP_DISTANCE_THRESHOLD = 60; + + private static final int TOUCH_DOWN_DEAD_ZONE_TIME_THRESHOLD = 100; + private static final int TOUCH_DOWN_DEAD_ZONE_DISTANCE_THRESHOLD = 20; + + public AbsoluteTouchContext(NvConnection conn, int actionIndex, View view) + { + this.conn = conn; + this.actionIndex = actionIndex; + this.targetView = view; + this.handler = new Handler(Looper.getMainLooper()); + } + + @Override + public int getActionIndex() + { + return actionIndex; + } + + @Override + public boolean touchDownEvent(int eventX, int eventY, long eventTime, boolean isNewFinger) + { + if (!isNewFinger) { + // We don't handle finger transitions for absolute mode + return true; + } + + lastTouchLocationX = lastTouchDownX = eventX; + lastTouchLocationY = lastTouchDownY = eventY; + lastTouchDownTime = eventTime; + cancelled = confirmedTap = confirmedLongPress = false; + + if (actionIndex == 0) { + // Start the timers + startTapDownTimer(); + startLongPressTimer(); + } + + return true; + } + + private boolean distanceExceeds(int deltaX, int deltaY, double limit) { + return Math.sqrt(Math.pow(deltaX, 2) + Math.pow(deltaY, 2)) > limit; + } + + private void updatePosition(int eventX, int eventY) { + // We may get values slightly outside our view region on ACTION_HOVER_ENTER and ACTION_HOVER_EXIT. + // Normalize these to the view size. We can't just drop them because we won't always get an event + // right at the boundary of the view, so dropping them would result in our cursor never really + // reaching the sides of the screen. + eventX = Math.min(Math.max(eventX, 0), targetView.getWidth()); + eventY = Math.min(Math.max(eventY, 0), targetView.getHeight()); + + conn.sendMousePosition((short)eventX, (short)eventY, (short)targetView.getWidth(), (short)targetView.getHeight()); + } + + @Override + public void touchUpEvent(int eventX, int eventY, long eventTime) + { + if (cancelled) { + return; + } + + if (actionIndex == 0) { + // Cancel the timers + cancelLongPressTimer(); + cancelTapDownTimer(); + + // Raise the mouse buttons that we currently have down + if (confirmedLongPress) { + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_RIGHT); + } + else if (confirmedTap) { + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT); + } + else { + // If we get here, this means that the tap completed within the touch down + // deadzone time. We'll need to send the touch down and up events now at the + // original touch down position. + tapConfirmed(); + + // Release the left mouse button in 100ms to allow for apps that use polling + // to detect mouse button presses. + handler.removeCallbacks(leftButtonUpRunnable); + handler.postDelayed(leftButtonUpRunnable, 100); + } + } + + lastTouchLocationX = lastTouchUpX = eventX; + lastTouchLocationY = lastTouchUpY = eventY; + lastTouchUpTime = eventTime; + } + + private void startLongPressTimer() { + cancelLongPressTimer(); + handler.postDelayed(longPressRunnable, LONG_PRESS_TIME_THRESHOLD); + } + + private void cancelLongPressTimer() { + handler.removeCallbacks(longPressRunnable); + } + + private void startTapDownTimer() { + cancelTapDownTimer(); + handler.postDelayed(tapDownRunnable, TOUCH_DOWN_DEAD_ZONE_TIME_THRESHOLD); + } + + private void cancelTapDownTimer() { + handler.removeCallbacks(tapDownRunnable); + } + + private void tapConfirmed() { + if (confirmedTap || confirmedLongPress) { + return; + } + + confirmedTap = true; + cancelTapDownTimer(); + + // Left button down at original position + if (lastTouchDownTime - lastTouchUpTime > DOUBLE_TAP_TIME_THRESHOLD || + distanceExceeds(lastTouchDownX - lastTouchUpX, lastTouchDownY - lastTouchUpY, DOUBLE_TAP_DISTANCE_THRESHOLD)) { + // Don't reposition for finger down events within the deadzone. This makes double-clicking easier. + updatePosition(lastTouchDownX, lastTouchDownY); + } + conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_LEFT); + } + + @Override + public boolean touchMoveEvent(int eventX, int eventY, long eventTime) + { + if (cancelled) { + return true; + } + + if (actionIndex == 0) { + if (distanceExceeds(eventX - lastTouchDownX, eventY - lastTouchDownY, LONG_PRESS_DISTANCE_THRESHOLD)) { + // Moved too far since touch down. Cancel the long press timer. + cancelLongPressTimer(); + } + + // Ignore motion within the deadzone period after touch down + if (confirmedTap || distanceExceeds(eventX - lastTouchDownX, eventY - lastTouchDownY, TOUCH_DOWN_DEAD_ZONE_DISTANCE_THRESHOLD)) { + tapConfirmed(); + updatePosition(eventX, eventY); + } + } + else if (actionIndex == 1) { + conn.sendMouseHighResScroll((short)((eventY - lastTouchLocationY) * SCROLL_SPEED_FACTOR)); + } + + lastTouchLocationX = eventX; + lastTouchLocationY = eventY; + + return true; + } + + @Override + public void cancelTouch() { + cancelled = true; + + // Cancel the timers + cancelLongPressTimer(); + cancelTapDownTimer(); + + // Raise the mouse buttons + if (confirmedLongPress) { + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_RIGHT); + } + else if (confirmedTap) { + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT); + } + } + + @Override + public boolean isCancelled() { + return cancelled; + } + + @Override + public void setPointerCount(int pointerCount) { + if (actionIndex == 0 && pointerCount > 1) { + cancelTouch(); + } + } +} diff --git a/app/src/main/java/com/limelight/binding/input/touch/AbsoluteTouchSwitchContext.java b/app/src/main/java/com/limelight/binding/input/touch/AbsoluteTouchSwitchContext.java new file mode 100755 index 0000000000..b53e25d9a5 --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/touch/AbsoluteTouchSwitchContext.java @@ -0,0 +1,249 @@ +package com.limelight.binding.input.touch; + +import android.os.Handler; +import android.os.Looper; +import android.view.View; + +import com.limelight.nvstream.NvConnection; +import com.limelight.nvstream.input.MouseButtonPacket; + +public class AbsoluteTouchSwitchContext implements TouchContext { + private int lastTouchDownX = 0; + private int lastTouchDownY = 0; + private long lastTouchDownTime = 0; + private int lastTouchUpX = 0; + private int lastTouchUpY = 0; + private long lastTouchUpTime = 0; + private int lastTouchLocationX = 0; + private int lastTouchLocationY = 0; + private boolean cancelled; + private boolean confirmedLongPress; + private boolean confirmedTap; + + private final Runnable longPressRunnable = new Runnable() { + @Override + public void run() { + // This timer should have already expired, but cancel it just in case + cancelTapDownTimer(); + + // Switch from a left click to a right click after a long press + confirmedLongPress = true; + if (confirmedTap) { + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_RIGHT); + } + conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_LEFT); + } + }; + + private final Runnable tapDownRunnable = new Runnable() { + @Override + public void run() { + // Start our tap + tapConfirmed(); + } + }; + + private final NvConnection conn; + private final int actionIndex; + private final View targetView; + private final Handler handler; + + private final Runnable leftButtonUpRunnable = new Runnable() { + @Override + public void run() { + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_RIGHT); + } + }; + + private static final int SCROLL_SPEED_FACTOR = 3; + + private static final int LONG_PRESS_TIME_THRESHOLD = 650; + private static final int LONG_PRESS_DISTANCE_THRESHOLD = 30; + + private static final int DOUBLE_TAP_TIME_THRESHOLD = 250; + private static final int DOUBLE_TAP_DISTANCE_THRESHOLD = 60; + + private static final int TOUCH_DOWN_DEAD_ZONE_TIME_THRESHOLD = 100; + private static final int TOUCH_DOWN_DEAD_ZONE_DISTANCE_THRESHOLD = 20; + + public AbsoluteTouchSwitchContext(NvConnection conn, int actionIndex, View view) + { + this.conn = conn; + this.actionIndex = actionIndex; + this.targetView = view; + this.handler = new Handler(Looper.getMainLooper()); + } + + @Override + public int getActionIndex() + { + return actionIndex; + } + + @Override + public boolean touchDownEvent(int eventX, int eventY, long eventTime, boolean isNewFinger) + { + if (!isNewFinger) { + // We don't handle finger transitions for absolute mode + return true; + } + + lastTouchLocationX = lastTouchDownX = eventX; + lastTouchLocationY = lastTouchDownY = eventY; + lastTouchDownTime = eventTime; + cancelled = confirmedTap = confirmedLongPress = false; + + if (actionIndex == 0) { + // Start the timers + startTapDownTimer(); + startLongPressTimer(); + } + + return true; + } + + private boolean distanceExceeds(int deltaX, int deltaY, double limit) { + return Math.sqrt(Math.pow(deltaX, 2) + Math.pow(deltaY, 2)) > limit; + } + + private void updatePosition(int eventX, int eventY) { + // We may get values slightly outside our view region on ACTION_HOVER_ENTER and ACTION_HOVER_EXIT. + // Normalize these to the view size. We can't just drop them because we won't always get an event + // right at the boundary of the view, so dropping them would result in our cursor never really + // reaching the sides of the screen. + eventX = Math.min(Math.max(eventX, 0), targetView.getWidth()); + eventY = Math.min(Math.max(eventY, 0), targetView.getHeight()); + + conn.sendMousePosition((short)eventX, (short)eventY, (short)targetView.getWidth(), (short)targetView.getHeight()); + } + + @Override + public void touchUpEvent(int eventX, int eventY, long eventTime) + { + if (cancelled) { + return; + } + + if (actionIndex == 0) { + // Cancel the timers + cancelLongPressTimer(); + cancelTapDownTimer(); + + // Raise the mouse buttons that we currently have down + if (confirmedLongPress) { + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT); + } + else if (confirmedTap) { + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_RIGHT); + } + else { + // If we get here, this means that the tap completed within the touch down + // deadzone time. We'll need to send the touch down and up events now at the + // original touch down position. + tapConfirmed(); + + // Release the left mouse button in 100ms to allow for apps that use polling + // to detect mouse button presses. + handler.removeCallbacks(leftButtonUpRunnable); + handler.postDelayed(leftButtonUpRunnable, 100); + } + } + + lastTouchLocationX = lastTouchUpX = eventX; + lastTouchLocationY = lastTouchUpY = eventY; + lastTouchUpTime = eventTime; + } + + private void startLongPressTimer() { + cancelLongPressTimer(); + handler.postDelayed(longPressRunnable, LONG_PRESS_TIME_THRESHOLD); + } + + private void cancelLongPressTimer() { + handler.removeCallbacks(longPressRunnable); + } + + private void startTapDownTimer() { + cancelTapDownTimer(); + handler.postDelayed(tapDownRunnable, TOUCH_DOWN_DEAD_ZONE_TIME_THRESHOLD); + } + + private void cancelTapDownTimer() { + handler.removeCallbacks(tapDownRunnable); + } + + private void tapConfirmed() { + if (confirmedTap || confirmedLongPress) { + return; + } + + confirmedTap = true; + cancelTapDownTimer(); + + // Left button down at original position + if (lastTouchDownTime - lastTouchUpTime > DOUBLE_TAP_TIME_THRESHOLD || + distanceExceeds(lastTouchDownX - lastTouchUpX, lastTouchDownY - lastTouchUpY, DOUBLE_TAP_DISTANCE_THRESHOLD)) { + // Don't reposition for finger down events within the deadzone. This makes double-clicking easier. + updatePosition(lastTouchDownX, lastTouchDownY); + } + conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_RIGHT); + } + + @Override + public boolean touchMoveEvent(int eventX, int eventY, long eventTime) + { + if (cancelled) { + return true; + } + + if (actionIndex == 0) { + if (distanceExceeds(eventX - lastTouchDownX, eventY - lastTouchDownY, LONG_PRESS_DISTANCE_THRESHOLD)) { + // Moved too far since touch down. Cancel the long press timer. + cancelLongPressTimer(); + } + + // Ignore motion within the deadzone period after touch down + if (confirmedTap || distanceExceeds(eventX - lastTouchDownX, eventY - lastTouchDownY, TOUCH_DOWN_DEAD_ZONE_DISTANCE_THRESHOLD)) { + tapConfirmed(); + updatePosition(eventX, eventY); + } + } + else if (actionIndex == 1) { + conn.sendMouseHighResScroll((short)((eventY - lastTouchLocationY) * SCROLL_SPEED_FACTOR)); + } + + lastTouchLocationX = eventX; + lastTouchLocationY = eventY; + + return true; + } + + @Override + public void cancelTouch() { + cancelled = true; + + // Cancel the timers + cancelLongPressTimer(); + cancelTapDownTimer(); + + // Raise the mouse buttons + if (confirmedLongPress) { + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT); + } + else if (confirmedTap) { + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_RIGHT); + } + } + + @Override + public boolean isCancelled() { + return cancelled; + } + + @Override + public void setPointerCount(int pointerCount) { + if (actionIndex == 0 && pointerCount > 1) { + cancelTouch(); + } + } +} diff --git a/app/src/main/java/com/limelight/binding/input/touch/RelativeTouchContext.java b/app/src/main/java/com/limelight/binding/input/touch/RelativeTouchContext.java old mode 100644 new mode 100755 index 7ed8f09674..527fa6c252 --- a/app/src/main/java/com/limelight/binding/input/touch/RelativeTouchContext.java +++ b/app/src/main/java/com/limelight/binding/input/touch/RelativeTouchContext.java @@ -1,331 +1,331 @@ -package com.limelight.binding.input.touch; - -import android.os.Handler; -import android.os.Looper; -import android.view.View; - -import com.limelight.nvstream.NvConnection; -import com.limelight.nvstream.input.MouseButtonPacket; -import com.limelight.preferences.PreferenceConfiguration; - -public class RelativeTouchContext implements TouchContext { - private int lastTouchX = 0; - private int lastTouchY = 0; - private int originalTouchX = 0; - private int originalTouchY = 0; - private long originalTouchTime = 0; - private boolean cancelled; - private boolean confirmedMove; - private boolean confirmedDrag; - private boolean confirmedScroll; - private double distanceMoved; - private double xFactor, yFactor; - private int pointerCount; - private int maxPointerCountInGesture; - - private final NvConnection conn; - private final int actionIndex; - private final int referenceWidth; - private final int referenceHeight; - private final View targetView; - private final PreferenceConfiguration prefConfig; - private final Handler handler; - - private final Runnable dragTimerRunnable = new Runnable() { - @Override - public void run() { - // Check if someone already set move - if (confirmedMove) { - return; - } - - // The drag should only be processed for the primary finger - if (actionIndex != maxPointerCountInGesture - 1) { - return; - } - - // We haven't been cancelled before the timer expired so begin dragging - confirmedDrag = true; - conn.sendMouseButtonDown(getMouseButtonIndex()); - } - }; - - // Indexed by MouseButtonPacket.BUTTON_XXX - 1 - private final Runnable[] buttonUpRunnables = new Runnable[] { - new Runnable() { - @Override - public void run() { - conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT); - } - }, - new Runnable() { - @Override - public void run() { - conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_MIDDLE); - } - }, - new Runnable() { - @Override - public void run() { - conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_RIGHT); - } - }, - new Runnable() { - @Override - public void run() { - conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_X1); - } - }, - new Runnable() { - @Override - public void run() { - conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_X2); - } - } - }; - - private static final int TAP_MOVEMENT_THRESHOLD = 20; - private static final int TAP_DISTANCE_THRESHOLD = 25; - private static final int TAP_TIME_THRESHOLD = 250; - private static final int DRAG_TIME_THRESHOLD = 650; - - private static final int SCROLL_SPEED_FACTOR = 5; - - public RelativeTouchContext(NvConnection conn, int actionIndex, - int referenceWidth, int referenceHeight, - View view, PreferenceConfiguration prefConfig) - { - this.conn = conn; - this.actionIndex = actionIndex; - this.referenceWidth = referenceWidth; - this.referenceHeight = referenceHeight; - this.targetView = view; - this.prefConfig = prefConfig; - this.handler = new Handler(Looper.getMainLooper()); - } - - @Override - public int getActionIndex() - { - return actionIndex; - } - - private boolean isWithinTapBounds(int touchX, int touchY) - { - int xDelta = Math.abs(touchX - originalTouchX); - int yDelta = Math.abs(touchY - originalTouchY); - return xDelta <= TAP_MOVEMENT_THRESHOLD && - yDelta <= TAP_MOVEMENT_THRESHOLD; - } - - private boolean isTap(long eventTime) - { - if (confirmedDrag || confirmedMove || confirmedScroll) { - return false; - } - - // If this input wasn't the last finger down, do not report - // a tap. This ensures we don't report duplicate taps for each - // finger on a multi-finger tap gesture - if (actionIndex + 1 != maxPointerCountInGesture) { - return false; - } - - long timeDelta = eventTime - originalTouchTime; - return isWithinTapBounds(lastTouchX, lastTouchY) && timeDelta <= TAP_TIME_THRESHOLD; - } - - private byte getMouseButtonIndex() - { - if (actionIndex == 1) { - return MouseButtonPacket.BUTTON_RIGHT; - } - else { - return MouseButtonPacket.BUTTON_LEFT; - } - } - - @Override - public boolean touchDownEvent(int eventX, int eventY, long eventTime, boolean isNewFinger) - { - // Get the view dimensions to scale inputs on this touch - xFactor = referenceWidth / (double)targetView.getWidth(); - yFactor = referenceHeight / (double)targetView.getHeight(); - - originalTouchX = lastTouchX = eventX; - originalTouchY = lastTouchY = eventY; - - if (isNewFinger) { - maxPointerCountInGesture = pointerCount; - originalTouchTime = eventTime; - cancelled = confirmedDrag = confirmedMove = confirmedScroll = false; - distanceMoved = 0; - - if (actionIndex == 0) { - // Start the timer for engaging a drag - startDragTimer(); - } - } - - return true; - } - - @Override - public void touchUpEvent(int eventX, int eventY, long eventTime) - { - if (cancelled) { - return; - } - - // Cancel the drag timer - cancelDragTimer(); - - byte buttonIndex = getMouseButtonIndex(); - - if (confirmedDrag) { - // Raise the button after a drag - conn.sendMouseButtonUp(buttonIndex); - } - else if (isTap(eventTime)) - { - // Lower the mouse button - conn.sendMouseButtonDown(buttonIndex); - - // Release the mouse button in 100ms to allow for apps that use polling - // to detect mouse button presses. - Runnable buttonUpRunnable = buttonUpRunnables[buttonIndex - 1]; - handler.removeCallbacks(buttonUpRunnable); - handler.postDelayed(buttonUpRunnable, 100); - } - } - - private void startDragTimer() { - cancelDragTimer(); - handler.postDelayed(dragTimerRunnable, DRAG_TIME_THRESHOLD); - } - - private void cancelDragTimer() { - handler.removeCallbacks(dragTimerRunnable); - } - - private void checkForConfirmedMove(int eventX, int eventY) { - // If we've already confirmed something, get out now - if (confirmedMove || confirmedDrag) { - return; - } - - // If it leaves the tap bounds before the drag time expires, it's a move. - if (!isWithinTapBounds(eventX, eventY)) { - confirmedMove = true; - cancelDragTimer(); - return; - } - - // Check if we've exceeded the maximum distance moved - distanceMoved += Math.sqrt(Math.pow(eventX - lastTouchX, 2) + Math.pow(eventY - lastTouchY, 2)); - if (distanceMoved >= TAP_DISTANCE_THRESHOLD) { - confirmedMove = true; - cancelDragTimer(); - return; - } - } - - private void checkForConfirmedScroll() { - // Enter scrolling mode if we've already left the tap zone - // and we have 2 fingers on screen. Leave scroll mode if - // we no longer have 2 fingers on screen - confirmedScroll = (actionIndex == 0 && pointerCount == 2 && confirmedMove); - } - - @Override - public boolean touchMoveEvent(int eventX, int eventY, long eventTime) - { - if (cancelled) { - return true; - } - - if (eventX != lastTouchX || eventY != lastTouchY) - { - checkForConfirmedMove(eventX, eventY); - checkForConfirmedScroll(); - - // We only send moves and drags for the primary touch point - if (actionIndex == 0) { - int deltaX = eventX - lastTouchX; - int deltaY = eventY - lastTouchY; - - // Scale the deltas based on the factors passed to our constructor - deltaX = (int) Math.round((double) Math.abs(deltaX) * xFactor); - deltaY = (int) Math.round((double) Math.abs(deltaY) * yFactor); - - // Fix up the signs - if (eventX < lastTouchX) { - deltaX = -deltaX; - } - if (eventY < lastTouchY) { - deltaY = -deltaY; - } - - if (pointerCount == 2) { - if (confirmedScroll) { - conn.sendMouseHighResScroll((short)(deltaY * SCROLL_SPEED_FACTOR)); - } - } else { - if (prefConfig.absoluteMouseMode) { - conn.sendMouseMoveAsMousePosition( - (short) deltaX, - (short) deltaY, - (short) targetView.getWidth(), - (short) targetView.getHeight()); - } - else { - conn.sendMouseMove((short) deltaX, (short) deltaY); - } - } - - // If the scaling factor ended up rounding deltas to zero, wait until they are - // non-zero to update lastTouch that way devices that report small touch events often - // will work correctly - if (deltaX != 0) { - lastTouchX = eventX; - } - if (deltaY != 0) { - lastTouchY = eventY; - } - } - else { - lastTouchX = eventX; - lastTouchY = eventY; - } - } - - return true; - } - - @Override - public void cancelTouch() { - cancelled = true; - - // Cancel the drag timer - cancelDragTimer(); - - // If it was a confirmed drag, we'll need to raise the button now - if (confirmedDrag) { - conn.sendMouseButtonUp(getMouseButtonIndex()); - } - } - - @Override - public boolean isCancelled() { - return cancelled; - } - - @Override - public void setPointerCount(int pointerCount) { - this.pointerCount = pointerCount; - - if (pointerCount > maxPointerCountInGesture) { - maxPointerCountInGesture = pointerCount; - } - } -} +package com.limelight.binding.input.touch; + +import android.os.Handler; +import android.os.Looper; +import android.view.View; + +import com.limelight.nvstream.NvConnection; +import com.limelight.nvstream.input.MouseButtonPacket; +import com.limelight.preferences.PreferenceConfiguration; + +public class RelativeTouchContext implements TouchContext { + private int lastTouchX = 0; + private int lastTouchY = 0; + private int originalTouchX = 0; + private int originalTouchY = 0; + private long originalTouchTime = 0; + private boolean cancelled; + private boolean confirmedMove; + private boolean confirmedDrag; + private boolean confirmedScroll; + private double distanceMoved; + private double xFactor, yFactor; + private int pointerCount; + private int maxPointerCountInGesture; + + private final NvConnection conn; + private final int actionIndex; + private final int referenceWidth; + private final int referenceHeight; + private final View targetView; + private final PreferenceConfiguration prefConfig; + private final Handler handler; + + private final Runnable dragTimerRunnable = new Runnable() { + @Override + public void run() { + // Check if someone already set move + if (confirmedMove) { + return; + } + + // The drag should only be processed for the primary finger + if (actionIndex != maxPointerCountInGesture - 1) { + return; + } + + // We haven't been cancelled before the timer expired so begin dragging + confirmedDrag = true; + conn.sendMouseButtonDown(getMouseButtonIndex()); + } + }; + + // Indexed by MouseButtonPacket.BUTTON_XXX - 1 + private final Runnable[] buttonUpRunnables = new Runnable[] { + new Runnable() { + @Override + public void run() { + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT); + } + }, + new Runnable() { + @Override + public void run() { + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_MIDDLE); + } + }, + new Runnable() { + @Override + public void run() { + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_RIGHT); + } + }, + new Runnable() { + @Override + public void run() { + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_X1); + } + }, + new Runnable() { + @Override + public void run() { + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_X2); + } + } + }; + + private static final int TAP_MOVEMENT_THRESHOLD = 20; + private static final int TAP_DISTANCE_THRESHOLD = 25; + private static final int TAP_TIME_THRESHOLD = 250; + private static final int DRAG_TIME_THRESHOLD = 650; + + private static final int SCROLL_SPEED_FACTOR = 5; + + public RelativeTouchContext(NvConnection conn, int actionIndex, + int referenceWidth, int referenceHeight, + View view, PreferenceConfiguration prefConfig) + { + this.conn = conn; + this.actionIndex = actionIndex; + this.referenceWidth = referenceWidth; + this.referenceHeight = referenceHeight; + this.targetView = view; + this.prefConfig = prefConfig; + this.handler = new Handler(Looper.getMainLooper()); + } + + @Override + public int getActionIndex() + { + return actionIndex; + } + + private boolean isWithinTapBounds(int touchX, int touchY) + { + int xDelta = Math.abs(touchX - originalTouchX); + int yDelta = Math.abs(touchY - originalTouchY); + return xDelta <= TAP_MOVEMENT_THRESHOLD && + yDelta <= TAP_MOVEMENT_THRESHOLD; + } + + private boolean isTap(long eventTime) + { + if (confirmedDrag || confirmedMove || confirmedScroll) { + return false; + } + + // If this input wasn't the last finger down, do not report + // a tap. This ensures we don't report duplicate taps for each + // finger on a multi-finger tap gesture + if (actionIndex + 1 != maxPointerCountInGesture) { + return false; + } + + long timeDelta = eventTime - originalTouchTime; + return isWithinTapBounds(lastTouchX, lastTouchY) && timeDelta <= TAP_TIME_THRESHOLD; + } + + private byte getMouseButtonIndex() + { + if (actionIndex == 1) { + return MouseButtonPacket.BUTTON_RIGHT; + } + else { + return MouseButtonPacket.BUTTON_LEFT; + } + } + + @Override + public boolean touchDownEvent(int eventX, int eventY, long eventTime, boolean isNewFinger) + { + // Get the view dimensions to scale inputs on this touch + xFactor = referenceWidth / (double)targetView.getWidth(); + yFactor = referenceHeight / (double)targetView.getHeight(); + + originalTouchX = lastTouchX = eventX; + originalTouchY = lastTouchY = eventY; + + if (isNewFinger) { + maxPointerCountInGesture = pointerCount; + originalTouchTime = eventTime; + cancelled = confirmedDrag = confirmedMove = confirmedScroll = false; + distanceMoved = 0; + + if (actionIndex == 0) { + // Start the timer for engaging a drag + startDragTimer(); + } + } + + return true; + } + + @Override + public void touchUpEvent(int eventX, int eventY, long eventTime) + { + if (cancelled) { + return; + } + + // Cancel the drag timer + cancelDragTimer(); + + byte buttonIndex = getMouseButtonIndex(); + + if (confirmedDrag) { + // Raise the button after a drag + conn.sendMouseButtonUp(buttonIndex); + } + else if (isTap(eventTime)) + { + // Lower the mouse button + conn.sendMouseButtonDown(buttonIndex); + + // Release the mouse button in 100ms to allow for apps that use polling + // to detect mouse button presses. + Runnable buttonUpRunnable = buttonUpRunnables[buttonIndex - 1]; + handler.removeCallbacks(buttonUpRunnable); + handler.postDelayed(buttonUpRunnable, 100); + } + } + + private void startDragTimer() { + cancelDragTimer(); + handler.postDelayed(dragTimerRunnable, DRAG_TIME_THRESHOLD); + } + + private void cancelDragTimer() { + handler.removeCallbacks(dragTimerRunnable); + } + + private void checkForConfirmedMove(int eventX, int eventY) { + // If we've already confirmed something, get out now + if (confirmedMove || confirmedDrag) { + return; + } + + // If it leaves the tap bounds before the drag time expires, it's a move. + if (!isWithinTapBounds(eventX, eventY)) { + confirmedMove = true; + cancelDragTimer(); + return; + } + + // Check if we've exceeded the maximum distance moved + distanceMoved += Math.sqrt(Math.pow(eventX - lastTouchX, 2) + Math.pow(eventY - lastTouchY, 2)); + if (distanceMoved >= TAP_DISTANCE_THRESHOLD) { + confirmedMove = true; + cancelDragTimer(); + return; + } + } + + private void checkForConfirmedScroll() { + // Enter scrolling mode if we've already left the tap zone + // and we have 2 fingers on screen. Leave scroll mode if + // we no longer have 2 fingers on screen + confirmedScroll = (actionIndex == 0 && pointerCount == 2 && confirmedMove); + } + + @Override + public boolean touchMoveEvent(int eventX, int eventY, long eventTime) + { + if (cancelled) { + return true; + } + + if (eventX != lastTouchX || eventY != lastTouchY) + { + checkForConfirmedMove(eventX, eventY); + checkForConfirmedScroll(); + + // We only send moves and drags for the primary touch point + if (actionIndex == 0) { + int deltaX = eventX - lastTouchX; + int deltaY = eventY - lastTouchY; + + // Scale the deltas based on the factors passed to our constructor + deltaX = (int) Math.round((double) Math.abs(deltaX) * xFactor); + deltaY = (int) Math.round((double) Math.abs(deltaY) * yFactor); + + // Fix up the signs + if (eventX < lastTouchX) { + deltaX = -deltaX; + } + if (eventY < lastTouchY) { + deltaY = -deltaY; + } + + if (pointerCount == 2) { + if (confirmedScroll) { + conn.sendMouseHighResScroll((short)(deltaY * SCROLL_SPEED_FACTOR)); + } + } else { + if (prefConfig.absoluteMouseMode) { + conn.sendMouseMoveAsMousePosition( + (short) deltaX, + (short) deltaY, + (short) targetView.getWidth(), + (short) targetView.getHeight()); + } + else { + conn.sendMouseMove((short) (deltaX*prefConfig.touchPadSensitivity*0.01f), (short) (deltaY*prefConfig.touchPadYSensitity*0.01f)); + } + } + + // If the scaling factor ended up rounding deltas to zero, wait until they are + // non-zero to update lastTouch that way devices that report small touch events often + // will work correctly + if (deltaX != 0) { + lastTouchX = eventX; + } + if (deltaY != 0) { + lastTouchY = eventY; + } + } + else { + lastTouchX = eventX; + lastTouchY = eventY; + } + } + + return true; + } + + @Override + public void cancelTouch() { + cancelled = true; + + // Cancel the drag timer + cancelDragTimer(); + + // If it was a confirmed drag, we'll need to raise the button now + if (confirmedDrag) { + conn.sendMouseButtonUp(getMouseButtonIndex()); + } + } + + @Override + public boolean isCancelled() { + return cancelled; + } + + @Override + public void setPointerCount(int pointerCount) { + this.pointerCount = pointerCount; + + if (pointerCount > maxPointerCountInGesture) { + maxPointerCountInGesture = pointerCount; + } + } +} diff --git a/app/src/main/java/com/limelight/binding/input/touch/RelativeTouchSwitchContext.java b/app/src/main/java/com/limelight/binding/input/touch/RelativeTouchSwitchContext.java new file mode 100755 index 0000000000..68dedc1a6c --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/touch/RelativeTouchSwitchContext.java @@ -0,0 +1,331 @@ +package com.limelight.binding.input.touch; + +import android.os.Handler; +import android.os.Looper; +import android.view.View; + +import com.limelight.nvstream.NvConnection; +import com.limelight.nvstream.input.MouseButtonPacket; +import com.limelight.preferences.PreferenceConfiguration; + +public class RelativeTouchSwitchContext implements TouchContext { + private int lastTouchX = 0; + private int lastTouchY = 0; + private int originalTouchX = 0; + private int originalTouchY = 0; + private long originalTouchTime = 0; + private boolean cancelled; + private boolean confirmedMove; + private boolean confirmedDrag; + private boolean confirmedScroll; + private double distanceMoved; + private double xFactor, yFactor; + private int pointerCount; + private int maxPointerCountInGesture; + + private final NvConnection conn; + private final int actionIndex; + private final int referenceWidth; + private final int referenceHeight; + private final View targetView; + private final PreferenceConfiguration prefConfig; + private final Handler handler; + + private final Runnable dragTimerRunnable = new Runnable() { + @Override + public void run() { + // Check if someone already set move + if (confirmedMove) { + return; + } + + // The drag should only be processed for the primary finger + if (actionIndex != maxPointerCountInGesture - 1) { + return; + } + + // We haven't been cancelled before the timer expired so begin dragging + confirmedDrag = true; + conn.sendMouseButtonDown(getMouseButtonIndex()); + } + }; + + // Indexed by MouseButtonPacket.BUTTON_XXX - 1 + private final Runnable[] buttonUpRunnables = new Runnable[] { + new Runnable() { + @Override + public void run() { + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT); + } + }, + new Runnable() { + @Override + public void run() { + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_MIDDLE); + } + }, + new Runnable() { + @Override + public void run() { + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_RIGHT); + } + }, + new Runnable() { + @Override + public void run() { + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_X1); + } + }, + new Runnable() { + @Override + public void run() { + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_X2); + } + } + }; + + private static final int TAP_MOVEMENT_THRESHOLD = 20; + private static final int TAP_DISTANCE_THRESHOLD = 25; + private static final int TAP_TIME_THRESHOLD = 100; + private static final int DRAG_TIME_THRESHOLD = 160; + + private static final int SCROLL_SPEED_FACTOR = 8; + + public RelativeTouchSwitchContext(NvConnection conn, int actionIndex, + int referenceWidth, int referenceHeight, + View view, PreferenceConfiguration prefConfig) + { + this.conn = conn; + this.actionIndex = actionIndex; + this.referenceWidth = referenceWidth; + this.referenceHeight = referenceHeight; + this.targetView = view; + this.prefConfig = prefConfig; + this.handler = new Handler(Looper.getMainLooper()); + } + + @Override + public int getActionIndex() + { + return actionIndex; + } + + private boolean isWithinTapBounds(int touchX, int touchY) + { + int xDelta = Math.abs(touchX - originalTouchX); + int yDelta = Math.abs(touchY - originalTouchY); + return xDelta <= TAP_MOVEMENT_THRESHOLD && + yDelta <= TAP_MOVEMENT_THRESHOLD; + } + + private boolean isTap(long eventTime) + { + if (confirmedDrag || confirmedMove || confirmedScroll) { + return false; + } + + // If this input wasn't the last finger down, do not report + // a tap. This ensures we don't report duplicate taps for each + // finger on a multi-finger tap gesture + if (actionIndex + 1 != maxPointerCountInGesture) { + return false; + } + + long timeDelta = eventTime - originalTouchTime; + return isWithinTapBounds(lastTouchX, lastTouchY) && timeDelta <= TAP_TIME_THRESHOLD; + } + + private byte getMouseButtonIndex() + { + if (actionIndex == 1) { + return MouseButtonPacket.BUTTON_LEFT; + } + else { + return MouseButtonPacket.BUTTON_RIGHT; + } + } + + @Override + public boolean touchDownEvent(int eventX, int eventY, long eventTime, boolean isNewFinger) + { + // Get the view dimensions to scale inputs on this touch + xFactor = referenceWidth / (double)targetView.getWidth(); + yFactor = referenceHeight / (double)targetView.getHeight(); + + originalTouchX = lastTouchX = eventX; + originalTouchY = lastTouchY = eventY; + + if (isNewFinger) { + maxPointerCountInGesture = pointerCount; + originalTouchTime = eventTime; + cancelled = confirmedDrag = confirmedMove = confirmedScroll = false; + distanceMoved = 0; + + if (actionIndex == 0) { + // Start the timer for engaging a drag + startDragTimer(); + } + } + + return true; + } + + @Override + public void touchUpEvent(int eventX, int eventY, long eventTime) + { + if (cancelled) { + return; + } + + // Cancel the drag timer + cancelDragTimer(); + +// byte buttonIndex = getMouseButtonIndex(); + + if (confirmedDrag) { + // Raise the button after a drag + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_RIGHT); + } + else if (isTap(eventTime)) + { + // Lower the mouse button + conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_LEFT); + + // Release the mouse button in 100ms to allow for apps that use polling + // to detect mouse button presses. + Runnable buttonUpRunnable = buttonUpRunnables[MouseButtonPacket.BUTTON_LEFT - 1]; + handler.removeCallbacks(buttonUpRunnable); + handler.postDelayed(buttonUpRunnable, 100); + } + } + + private void startDragTimer() { + cancelDragTimer(); + handler.postDelayed(dragTimerRunnable, DRAG_TIME_THRESHOLD); + } + + private void cancelDragTimer() { + handler.removeCallbacks(dragTimerRunnable); + } + + private void checkForConfirmedMove(int eventX, int eventY) { + // If we've already confirmed something, get out now + if (confirmedMove || confirmedDrag) { + return; + } + + // If it leaves the tap bounds before the drag time expires, it's a move. + if (!isWithinTapBounds(eventX, eventY)) { + confirmedMove = true; + cancelDragTimer(); + return; + } + + // Check if we've exceeded the maximum distance moved + distanceMoved += Math.sqrt(Math.pow(eventX - lastTouchX, 2) + Math.pow(eventY - lastTouchY, 2)); + if (distanceMoved >= TAP_DISTANCE_THRESHOLD) { + confirmedMove = true; + cancelDragTimer(); + return; + } + } + + private void checkForConfirmedScroll() { + // Enter scrolling mode if we've already left the tap zone + // and we have 2 fingers on screen. Leave scroll mode if + // we no longer have 2 fingers on screen + confirmedScroll = (actionIndex == 0 && pointerCount == 2 && confirmedMove); + } + + @Override + public boolean touchMoveEvent(int eventX, int eventY, long eventTime) + { + if (cancelled) { + return true; + } + + if (eventX != lastTouchX || eventY != lastTouchY) + { + checkForConfirmedMove(eventX, eventY); + checkForConfirmedScroll(); + + // We only send moves and drags for the primary touch point + if (actionIndex == 0) { + int deltaX = eventX - lastTouchX; + int deltaY = eventY - lastTouchY; + + // Scale the deltas based on the factors passed to our constructor + deltaX = (int) Math.round((double) Math.abs(deltaX) * xFactor); + deltaY = (int) Math.round((double) Math.abs(deltaY) * yFactor); + + // Fix up the signs + if (eventX < lastTouchX) { + deltaX = -deltaX; + } + if (eventY < lastTouchY) { + deltaY = -deltaY; + } + + if (pointerCount == 2) { + if (confirmedScroll) { + conn.sendMouseHighResScroll((short)(deltaY * SCROLL_SPEED_FACTOR)); + } + } else { + if (prefConfig.absoluteMouseMode) { + conn.sendMouseMoveAsMousePosition( + (short) deltaX, + (short) deltaY, + (short) targetView.getWidth(), + (short) targetView.getHeight()); + } + else { + conn.sendMouseMove((short) (deltaX*1.5), (short) (deltaY*1.5)); + } + } + + // If the scaling factor ended up rounding deltas to zero, wait until they are + // non-zero to update lastTouch that way devices that report small touch events often + // will work correctly + if (deltaX != 0) { + lastTouchX = eventX; + } + if (deltaY != 0) { + lastTouchY = eventY; + } + } + else { + lastTouchX = eventX; + lastTouchY = eventY; + } + } + + return true; + } + + @Override + public void cancelTouch() { + cancelled = true; + + // Cancel the drag timer + cancelDragTimer(); + + // If it was a confirmed drag, we'll need to raise the button now + if (confirmedDrag) { + conn.sendMouseButtonUp(getMouseButtonIndex()); + } + } + + @Override + public boolean isCancelled() { + return cancelled; + } + + @Override + public void setPointerCount(int pointerCount) { + this.pointerCount = pointerCount; + + if (pointerCount > maxPointerCountInGesture) { + maxPointerCountInGesture = pointerCount; + } + } +} diff --git a/app/src/main/java/com/limelight/binding/input/touch/TouchContext.java b/app/src/main/java/com/limelight/binding/input/touch/TouchContext.java old mode 100644 new mode 100755 index a04388fd41..431ea18162 --- a/app/src/main/java/com/limelight/binding/input/touch/TouchContext.java +++ b/app/src/main/java/com/limelight/binding/input/touch/TouchContext.java @@ -1,11 +1,11 @@ -package com.limelight.binding.input.touch; - -public interface TouchContext { - int getActionIndex(); - void setPointerCount(int pointerCount); - boolean touchDownEvent(int eventX, int eventY, long eventTime, boolean isNewFinger); - boolean touchMoveEvent(int eventX, int eventY, long eventTime); - void touchUpEvent(int eventX, int eventY, long eventTime); - void cancelTouch(); - boolean isCancelled(); -} +package com.limelight.binding.input.touch; + +public interface TouchContext { + int getActionIndex(); + void setPointerCount(int pointerCount); + boolean touchDownEvent(int eventX, int eventY, long eventTime, boolean isNewFinger); + boolean touchMoveEvent(int eventX, int eventY, long eventTime); + void touchUpEvent(int eventX, int eventY, long eventTime); + void cancelTouch(); + boolean isCancelled(); +} diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/AnalogStick.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/AnalogStick.java old mode 100644 new mode 100755 index ceec138dc7..f4f26830a6 --- a/app/src/main/java/com/limelight/binding/input/virtual_controller/AnalogStick.java +++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/AnalogStick.java @@ -1,349 +1,349 @@ -/** - * Created by Karim Mreisi. - */ - -package com.limelight.binding.input.virtual_controller; - -import android.content.Context; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.Paint; -import android.view.MotionEvent; - -import java.util.ArrayList; -import java.util.List; - -/** - * This is a analog stick on screen element. It is used to get 2-Axis user input. - */ -public class AnalogStick extends VirtualControllerElement { - - /** - * outer radius size in percent of the ui element - */ - public static final int SIZE_RADIUS_COMPLETE = 90; - /** - * analog stick size in percent of the ui element - */ - public static final int SIZE_RADIUS_ANALOG_STICK = 90; - /** - * dead zone size in percent of the ui element - */ - public static final int SIZE_RADIUS_DEADZONE = 90; - /** - * time frame for a double click - */ - public final static long timeoutDoubleClick = 350; - - /** - * touch down time until the deadzone is lifted to allow precise movements with the analog sticks - */ - public final static long timeoutDeadzone = 150; - - /** - * Listener interface to update registered observers. - */ - public interface AnalogStickListener { - - /** - * onMovement event will be fired on real analog stick movement (outside of the deadzone). - * - * @param x horizontal position, value from -1.0 ... 0 .. 1.0 - * @param y vertical position, value from -1.0 ... 0 .. 1.0 - */ - void onMovement(float x, float y); - - /** - * onClick event will be fired on click on the analog stick - */ - void onClick(); - - /** - * onDoubleClick event will be fired on a double click in a short time frame on the analog - * stick. - */ - void onDoubleClick(); - - /** - * onRevoke event will be fired on unpress of the analog stick. - */ - void onRevoke(); - } - - /** - * Movement states of the analog sick. - */ - private enum STICK_STATE { - NO_MOVEMENT, - MOVED_IN_DEAD_ZONE, - MOVED_ACTIVE - } - - /** - * Click type states. - */ - private enum CLICK_STATE { - SINGLE, - DOUBLE - } - - /** - * configuration if the analog stick should be displayed as circle or square - */ - private boolean circle_stick = true; // TODO: implement square sick for simulations - - /** - * outer radius, this size will be automatically updated on resize - */ - private float radius_complete = 0; - /** - * analog stick radius, this size will be automatically updated on resize - */ - private float radius_analog_stick = 0; - /** - * dead zone radius, this size will be automatically updated on resize - */ - private float radius_dead_zone = 0; - - /** - * horizontal position in relation to the center of the element - */ - private float relative_x = 0; - /** - * vertical position in relation to the center of the element - */ - private float relative_y = 0; - - - private double movement_radius = 0; - private double movement_angle = 0; - - private float position_stick_x = 0; - private float position_stick_y = 0; - - private final Paint paint = new Paint(); - - private STICK_STATE stick_state = STICK_STATE.NO_MOVEMENT; - private CLICK_STATE click_state = CLICK_STATE.SINGLE; - - private List listeners = new ArrayList<>(); - private long timeLastClick = 0; - - private static double getMovementRadius(float x, float y) { - return Math.sqrt(x * x + y * y); - } - - private static double getAngle(float way_x, float way_y) { - // prevent divisions by zero for corner cases - if (way_x == 0) { - return way_y < 0 ? Math.PI : 0; - } else if (way_y == 0) { - if (way_x > 0) { - return Math.PI * 3 / 2; - } else if (way_x < 0) { - return Math.PI * 1 / 2; - } - } - // return correct calculated angle for each quadrant - if (way_x > 0) { - if (way_y < 0) { - // first quadrant - return 3 * Math.PI / 2 + Math.atan((double) (-way_y / way_x)); - } else { - // second quadrant - return Math.PI + Math.atan((double) (way_x / way_y)); - } - } else { - if (way_y > 0) { - // third quadrant - return Math.PI / 2 + Math.atan((double) (way_y / -way_x)); - } else { - // fourth quadrant - return 0 + Math.atan((double) (-way_x / -way_y)); - } - } - } - - public AnalogStick(VirtualController controller, Context context, int elementId) { - super(controller, context, elementId); - // reset stick position - position_stick_x = getWidth() / 2; - position_stick_y = getHeight() / 2; - } - - public void addAnalogStickListener(AnalogStickListener listener) { - listeners.add(listener); - } - - private void notifyOnMovement(float x, float y) { - _DBG("movement x: " + x + " movement y: " + y); - // notify listeners - for (AnalogStickListener listener : listeners) { - listener.onMovement(x, y); - } - } - - private void notifyOnClick() { - _DBG("click"); - // notify listeners - for (AnalogStickListener listener : listeners) { - listener.onClick(); - } - } - - private void notifyOnDoubleClick() { - _DBG("double click"); - // notify listeners - for (AnalogStickListener listener : listeners) { - listener.onDoubleClick(); - } - } - - private void notifyOnRevoke() { - _DBG("revoke"); - // notify listeners - for (AnalogStickListener listener : listeners) { - listener.onRevoke(); - } - } - - @Override - protected void onSizeChanged(int w, int h, int oldw, int oldh) { - // calculate new radius sizes depending - radius_complete = getPercent(getCorrectWidth() / 2, 100) - 2 * getDefaultStrokeWidth(); - radius_dead_zone = getPercent(getCorrectWidth() / 2, 30); - radius_analog_stick = getPercent(getCorrectWidth() / 2, 20); - - super.onSizeChanged(w, h, oldw, oldh); - } - - @Override - protected void onElementDraw(Canvas canvas) { - // set transparent background - canvas.drawColor(Color.TRANSPARENT); - - paint.setStyle(Paint.Style.STROKE); - paint.setStrokeWidth(getDefaultStrokeWidth()); - - // draw outer circle - if (!isPressed() || click_state == CLICK_STATE.SINGLE) { - paint.setColor(getDefaultColor()); - } else { - paint.setColor(pressedColor); - } - canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius_complete, paint); - - paint.setColor(getDefaultColor()); - // draw dead zone - canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius_dead_zone, paint); - - // draw stick depending on state - switch (stick_state) { - case NO_MOVEMENT: { - paint.setColor(getDefaultColor()); - canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius_analog_stick, paint); - break; - } - case MOVED_IN_DEAD_ZONE: - case MOVED_ACTIVE: { - paint.setColor(pressedColor); - canvas.drawCircle(position_stick_x, position_stick_y, radius_analog_stick, paint); - break; - } - } - } - - private void updatePosition(long eventTime) { - // get 100% way - float complete = radius_complete - radius_analog_stick; - - // calculate relative way - float correlated_y = (float) (Math.sin(Math.PI / 2 - movement_angle) * (movement_radius)); - float correlated_x = (float) (Math.cos(Math.PI / 2 - movement_angle) * (movement_radius)); - - // update positions - position_stick_x = getWidth() / 2 - correlated_x; - position_stick_y = getHeight() / 2 - correlated_y; - - // Stay active even if we're back in the deadzone because we know the user is actively - // giving analog stick input and we don't want to snap back into the deadzone. - // We also release the deadzone if the user keeps the stick pressed for a bit to allow - // them to make precise movements. - stick_state = (stick_state == STICK_STATE.MOVED_ACTIVE || - eventTime - timeLastClick > timeoutDeadzone || - movement_radius > radius_dead_zone) ? - STICK_STATE.MOVED_ACTIVE : STICK_STATE.MOVED_IN_DEAD_ZONE; - - // trigger move event if state active - if (stick_state == STICK_STATE.MOVED_ACTIVE) { - notifyOnMovement(-correlated_x / complete, correlated_y / complete); - } - } - - @Override - public boolean onElementTouchEvent(MotionEvent event) { - // save last click state - CLICK_STATE lastClickState = click_state; - - // get absolute way for each axis - relative_x = -(getWidth() / 2 - event.getX()); - relative_y = -(getHeight() / 2 - event.getY()); - - // get radius and angel of movement from center - movement_radius = getMovementRadius(relative_x, relative_y); - movement_angle = getAngle(relative_x, relative_y); - - // pass touch event to parent if out of outer circle - if (movement_radius > radius_complete && !isPressed()) - return false; - - // chop radius if out of outer circle or near the edge - if (movement_radius > (radius_complete - radius_analog_stick)) { - movement_radius = radius_complete - radius_analog_stick; - } - - // handle event depending on action - switch (event.getActionMasked()) { - // down event (touch event) - case MotionEvent.ACTION_DOWN: { - // set to dead zoned, will be corrected in update position if necessary - stick_state = STICK_STATE.MOVED_IN_DEAD_ZONE; - // check for double click - if (lastClickState == CLICK_STATE.SINGLE && - event.getEventTime() - timeLastClick <= timeoutDoubleClick) { - click_state = CLICK_STATE.DOUBLE; - notifyOnDoubleClick(); - } else { - click_state = CLICK_STATE.SINGLE; - notifyOnClick(); - } - // reset last click timestamp - timeLastClick = event.getEventTime(); - // set item pressed and update - setPressed(true); - break; - } - // up event (revoke touch) - case MotionEvent.ACTION_CANCEL: - case MotionEvent.ACTION_UP: { - setPressed(false); - break; - } - } - - if (isPressed()) { - // when is pressed calculate new positions (will trigger movement if necessary) - updatePosition(event.getEventTime()); - } else { - stick_state = STICK_STATE.NO_MOVEMENT; - notifyOnRevoke(); - - // not longer pressed reset analog stick - notifyOnMovement(0, 0); - } - // refresh view - invalidate(); - // accept the touch event - return true; - } -} +/** + * Created by Karim Mreisi. + */ + +package com.limelight.binding.input.virtual_controller; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.view.MotionEvent; + +import java.util.ArrayList; +import java.util.List; + +/** + * This is a analog stick on screen element. It is used to get 2-Axis user input. + */ +public class AnalogStick extends VirtualControllerElement { + + /** + * outer radius size in percent of the ui element + */ + public static final int SIZE_RADIUS_COMPLETE = 90; + /** + * analog stick size in percent of the ui element + */ + public static final int SIZE_RADIUS_ANALOG_STICK = 90; + /** + * dead zone size in percent of the ui element + */ + public static final int SIZE_RADIUS_DEADZONE = 90; + /** + * time frame for a double click + */ + public final static long timeoutDoubleClick = 350; + + /** + * touch down time until the deadzone is lifted to allow precise movements with the analog sticks + */ + public final static long timeoutDeadzone = 150; + + /** + * Listener interface to update registered observers. + */ + public interface AnalogStickListener { + + /** + * onMovement event will be fired on real analog stick movement (outside of the deadzone). + * + * @param x horizontal position, value from -1.0 ... 0 .. 1.0 + * @param y vertical position, value from -1.0 ... 0 .. 1.0 + */ + void onMovement(float x, float y); + + /** + * onClick event will be fired on click on the analog stick + */ + void onClick(); + + /** + * onDoubleClick event will be fired on a double click in a short time frame on the analog + * stick. + */ + void onDoubleClick(); + + /** + * onRevoke event will be fired on unpress of the analog stick. + */ + void onRevoke(); + } + + /** + * Movement states of the analog sick. + */ + private enum STICK_STATE { + NO_MOVEMENT, + MOVED_IN_DEAD_ZONE, + MOVED_ACTIVE + } + + /** + * Click type states. + */ + private enum CLICK_STATE { + SINGLE, + DOUBLE + } + + /** + * configuration if the analog stick should be displayed as circle or square + */ + private boolean circle_stick = true; // TODO: implement square sick for simulations + + /** + * outer radius, this size will be automatically updated on resize + */ + private float radius_complete = 0; + /** + * analog stick radius, this size will be automatically updated on resize + */ + private float radius_analog_stick = 0; + /** + * dead zone radius, this size will be automatically updated on resize + */ + private float radius_dead_zone = 0; + + /** + * horizontal position in relation to the center of the element + */ + private float relative_x = 0; + /** + * vertical position in relation to the center of the element + */ + private float relative_y = 0; + + + private double movement_radius = 0; + private double movement_angle = 0; + + private float position_stick_x = 0; + private float position_stick_y = 0; + + private final Paint paint = new Paint(); + + private STICK_STATE stick_state = STICK_STATE.NO_MOVEMENT; + private CLICK_STATE click_state = CLICK_STATE.SINGLE; + + private List listeners = new ArrayList<>(); + private long timeLastClick = 0; + + private static double getMovementRadius(float x, float y) { + return Math.sqrt(x * x + y * y); + } + + private static double getAngle(float way_x, float way_y) { + // prevent divisions by zero for corner cases + if (way_x == 0) { + return way_y < 0 ? Math.PI : 0; + } else if (way_y == 0) { + if (way_x > 0) { + return Math.PI * 3 / 2; + } else if (way_x < 0) { + return Math.PI * 1 / 2; + } + } + // return correct calculated angle for each quadrant + if (way_x > 0) { + if (way_y < 0) { + // first quadrant + return 3 * Math.PI / 2 + Math.atan((double) (-way_y / way_x)); + } else { + // second quadrant + return Math.PI + Math.atan((double) (way_x / way_y)); + } + } else { + if (way_y > 0) { + // third quadrant + return Math.PI / 2 + Math.atan((double) (way_y / -way_x)); + } else { + // fourth quadrant + return 0 + Math.atan((double) (-way_x / -way_y)); + } + } + } + + public AnalogStick(VirtualController controller, Context context, int elementId) { + super(controller, context, elementId); + // reset stick position + position_stick_x = getWidth() / 2; + position_stick_y = getHeight() / 2; + } + + public void addAnalogStickListener(AnalogStickListener listener) { + listeners.add(listener); + } + + private void notifyOnMovement(float x, float y) { + _DBG("movement x: " + x + " movement y: " + y); + // notify listeners + for (AnalogStickListener listener : listeners) { + listener.onMovement(x, y); + } + } + + private void notifyOnClick() { + _DBG("click"); + // notify listeners + for (AnalogStickListener listener : listeners) { + listener.onClick(); + } + } + + private void notifyOnDoubleClick() { + _DBG("double click"); + // notify listeners + for (AnalogStickListener listener : listeners) { + listener.onDoubleClick(); + } + } + + private void notifyOnRevoke() { + _DBG("revoke"); + // notify listeners + for (AnalogStickListener listener : listeners) { + listener.onRevoke(); + } + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + // calculate new radius sizes depending + radius_complete = getPercent(getCorrectWidth() / 2, 100) - 2 * getDefaultStrokeWidth(); + radius_dead_zone = getPercent(getCorrectWidth() / 2, 30); + radius_analog_stick = getPercent(getCorrectWidth() / 2, 20); + + super.onSizeChanged(w, h, oldw, oldh); + } + + @Override + protected void onElementDraw(Canvas canvas) { + // set transparent background + canvas.drawColor(Color.TRANSPARENT); + + paint.setStyle(Paint.Style.STROKE); + paint.setStrokeWidth(getDefaultStrokeWidth()); + + // draw outer circle + if (!isPressed() || click_state == CLICK_STATE.SINGLE) { + paint.setColor(getDefaultColor()); + } else { + paint.setColor(pressedColor); + } + canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius_complete, paint); + + paint.setColor(getDefaultColor()); + // draw dead zone + canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius_dead_zone, paint); + + // draw stick depending on state + switch (stick_state) { + case NO_MOVEMENT: { + paint.setColor(getDefaultColor()); + canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius_analog_stick, paint); + break; + } + case MOVED_IN_DEAD_ZONE: + case MOVED_ACTIVE: { + paint.setColor(pressedColor); + canvas.drawCircle(position_stick_x, position_stick_y, radius_analog_stick, paint); + break; + } + } + } + + private void updatePosition(long eventTime) { + // get 100% way + float complete = radius_complete - radius_analog_stick; + + // calculate relative way + float correlated_y = (float) (Math.sin(Math.PI / 2 - movement_angle) * (movement_radius)); + float correlated_x = (float) (Math.cos(Math.PI / 2 - movement_angle) * (movement_radius)); + + // update positions + position_stick_x = getWidth() / 2 - correlated_x; + position_stick_y = getHeight() / 2 - correlated_y; + + // Stay active even if we're back in the deadzone because we know the user is actively + // giving analog stick input and we don't want to snap back into the deadzone. + // We also release the deadzone if the user keeps the stick pressed for a bit to allow + // them to make precise movements. + stick_state = (stick_state == STICK_STATE.MOVED_ACTIVE || + eventTime - timeLastClick > timeoutDeadzone || + movement_radius > radius_dead_zone) ? + STICK_STATE.MOVED_ACTIVE : STICK_STATE.MOVED_IN_DEAD_ZONE; + + // trigger move event if state active + if (stick_state == STICK_STATE.MOVED_ACTIVE) { + notifyOnMovement(-correlated_x / complete, correlated_y / complete); + } + } + + @Override + public boolean onElementTouchEvent(MotionEvent event) { + // save last click state + CLICK_STATE lastClickState = click_state; + + // get absolute way for each axis + relative_x = -(getWidth() / 2 - event.getX()); + relative_y = -(getHeight() / 2 - event.getY()); + + // get radius and angel of movement from center + movement_radius = getMovementRadius(relative_x, relative_y); + movement_angle = getAngle(relative_x, relative_y); + + // pass touch event to parent if out of outer circle + if (movement_radius > radius_complete && !isPressed()) + return false; + + // chop radius if out of outer circle or near the edge + if (movement_radius > (radius_complete - radius_analog_stick)) { + movement_radius = radius_complete - radius_analog_stick; + } + + // handle event depending on action + switch (event.getActionMasked()) { + // down event (touch event) + case MotionEvent.ACTION_DOWN: { + // set to dead zoned, will be corrected in update position if necessary + stick_state = STICK_STATE.MOVED_IN_DEAD_ZONE; + // check for double click + if (lastClickState == CLICK_STATE.SINGLE && + event.getEventTime() - timeLastClick <= timeoutDoubleClick) { + click_state = CLICK_STATE.DOUBLE; + notifyOnDoubleClick(); + } else { + click_state = CLICK_STATE.SINGLE; + notifyOnClick(); + } + // reset last click timestamp + timeLastClick = event.getEventTime(); + // set item pressed and update + setPressed(true); + break; + } + // up event (revoke touch) + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: { + setPressed(false); + break; + } + } + + if (isPressed()) { + // when is pressed calculate new positions (will trigger movement if necessary) + updatePosition(event.getEventTime()); + } else { + stick_state = STICK_STATE.NO_MOVEMENT; + notifyOnRevoke(); + + // not longer pressed reset analog stick + notifyOnMovement(0, 0); + } + // refresh view + invalidate(); + // accept the touch event + return true; + } +} diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/AnalogStickFree.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/AnalogStickFree.java new file mode 100755 index 0000000000..70b104dad6 --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/AnalogStickFree.java @@ -0,0 +1,512 @@ +/** + * Created by Karim Mreisi. + */ + +package com.limelight.binding.input.virtual_controller; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.view.MotionEvent; + +import com.limelight.preferences.PreferenceConfiguration; + +import java.util.ArrayList; +import java.util.List; + +/** + * This is a analog stick on screen element. It is used to get 2-Axis user input. + */ +public class AnalogStickFree extends VirtualControllerElement { + + /** + * outer radius size in percent of the ui element + */ + public static final int SIZE_RADIUS_COMPLETE = 90; + /** + * analog stick size in percent of the ui element + */ + public static final int SIZE_RADIUS_ANALOG_STICK = 90; + /** + * dead zone size in percent of the ui element + */ + public static final int SIZE_RADIUS_DEADZONE = 90; + /** + * time frame for a double click + */ + public final static long timeoutDoubleClick = 350; + + /** + * touch down time until the deadzone is lifted to allow precise movements with the analog sticks + */ + public final static long timeoutDeadzone = 150; + + /** + * Listener interface to update registered observers. + */ + public interface AnalogStickListener { + + /** + * onMovement event will be fired on real analog stick movement (outside of the deadzone). + * + * @param x horizontal position, value from -1.0 ... 0 .. 1.0 + * @param y vertical position, value from -1.0 ... 0 .. 1.0 + */ + void onMovement(float x, float y); + + /** + * onClick event will be fired on click on the analog stick + */ + void onClick(); + + /** + * onDoubleClick event will be fired on a double click in a short time frame on the analog + * stick. + */ + void onDoubleClick(); + + /** + * onRevoke event will be fired on unpress of the analog stick. + */ + void onRevoke(); + } + + /** + * Movement states of the analog sick. + */ + private enum STICK_STATE { + NO_MOVEMENT, + MOVED_IN_DEAD_ZONE, + MOVED_ACTIVE + } + + /** + * Click type states. + */ + private enum CLICK_STATE { + SINGLE, + DOUBLE + } + + /** + * configuration if the analog stick should be displayed as circle or square + */ + private boolean circle_stick = true; // TODO: implement square sick for simulations + + /** + * outer radius, this size will be automatically updated on resize + */ + private float radius_complete = 0; + /** + * analog stick radius, this size will be automatically updated on resize + */ + private float radius_analog_stick = 0; + /** + * dead zone radius, this size will be automatically updated on resize + */ + private float radius_dead_zone = 0; + + /** + * horizontal position in relation to the center of the element + */ + private float relative_x = 0; + /** + * vertical position in relation to the center of the element + */ + private float relative_y = 0; + + private boolean bIsFingerOnScreen = false; + + private double movement_radius = 0; + private double movement_angle = 0; + + private float position_stick_x = 0; + private float position_stick_y = 0; + + private final Paint paint = new Paint(); + + private STICK_STATE stick_state = STICK_STATE.NO_MOVEMENT; + private CLICK_STATE click_state = CLICK_STATE.SINGLE; + + private List listeners = new ArrayList<>(); + private long timeLastClick = 0; + + private int touchID; + private float touchStartX; + private float touchStartY; + private float touchX; + private float touchY; + + private float touchMaxDistance = 120; + private float touchDeadZone = 20; + private float fDeadzoneSave = 0.01f; + + protected String strStickSide = "L"; + + private static double getMovementRadius(float x, float y) { + return Math.sqrt(x * x + y * y); + } + + private static double getAngle(float way_x, float way_y) { + // prevent divisions by zero for corner cases + if (way_x == 0) { + return way_y < 0 ? Math.PI : 0; + } else if (way_y == 0) { + if (way_x > 0) { + return Math.PI * 3 / 2; + } else if (way_x < 0) { + return Math.PI * 1 / 2; + } + } + // return correct calculated angle for each quadrant + if (way_x > 0) { + if (way_y < 0) { + // first quadrant + return 3 * Math.PI / 2 + Math.atan((double) (-way_y / way_x)); + } else { + // second quadrant + return Math.PI + Math.atan((double) (way_x / way_y)); + } + } else { + if (way_y > 0) { + // third quadrant + return Math.PI / 2 + Math.atan((double) (way_y / -way_x)); + } else { + // fourth quadrant + return 0 + Math.atan((double) (-way_x / -way_y)); + } + } + } + + public AnalogStickFree(VirtualController controller, Context context, int elementId) { + super(controller, context, elementId); + // reset stick position + position_stick_x = getWidth() / 2; + position_stick_y = getHeight() / 2; + } + + public void addAnalogStickListener(AnalogStickListener listener) { + listeners.add(listener); + } + + private void notifyOnMovement(float x, float y) { + _DBG("movement x: " + x + " movement y: " + y); + // notify listeners + for (AnalogStickListener listener : listeners) { + listener.onMovement(x, y); + } + } + + private void notifyOnClick() { + _DBG("click"); + // notify listeners + for (AnalogStickListener listener : listeners) { + listener.onClick(); + } + } + + private void notifyOnDoubleClick() { + _DBG("double click"); + // notify listeners + for (AnalogStickListener listener : listeners) { + listener.onDoubleClick(); + } + } + + private void notifyOnRevoke() { + _DBG("revoke"); + // notify listeners + for (AnalogStickListener listener : listeners) { + listener.onRevoke(); + } + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + // calculate new radius sizes depending + radius_complete = getPercent(getCorrectWidth() / 2, 100) - 2 * getDefaultStrokeWidth(); + radius_dead_zone = getPercent(getCorrectWidth() / 2, 30); + radius_analog_stick = getPercent(getCorrectWidth() / 2, 20); + + super.onSizeChanged(w, h, oldw, oldh); + } + + + @Override + protected void onElementDraw(Canvas canvas) { + boolean bIsMoving = virtualController.getControllerMode() == VirtualController.ControllerMode.MoveButtons; + boolean bIsResizing = virtualController.getControllerMode() == VirtualController.ControllerMode.ResizeButtons; + boolean bIsEnable = virtualController.getControllerMode() == VirtualController.ControllerMode.DisableEnableButtons; + + if (bIsMoving || bIsResizing || bIsEnable) { + canvas.drawColor(getDefaultColor()); + paint.setColor(Color.WHITE); + int nWidth = getWidth(); + int nHeight = getHeight(); + + paint.setStyle(Paint.Style.FILL); + paint.setTextSize(Math.min(nWidth, nHeight) / 2); + canvas.drawText(strStickSide, nWidth / 2, nHeight / 2, paint); + } + + paint.setStyle(Paint.Style.STROKE); + paint.setStrokeWidth(getDefaultStrokeWidth()); + + if (bIsFingerOnScreen) { + // set transparent background + canvas.drawColor(Color.TRANSPARENT); + //canvas.drawCircle(touchX, touchY, 50, paint); + + // draw outer circle +// if (!isPressed() || click_state == CLICK_STATE.SINGLE) { +// //paint.setColor(getDefaultColor()); +// } else { +// //paint.setColor(pressedColor); +// } +// //canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius_complete, paint); +// +// //paint.setColor(getDefaultColor()); +// // draw dead zone +// //canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius_dead_zone, paint); + // draw stick depending on state + switch (stick_state) { + case NO_MOVEMENT: { + paint.setColor(Color.MAGENTA); + canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius_analog_stick, paint); + break; + } + + case MOVED_IN_DEAD_ZONE: + case MOVED_ACTIVE: { + + paint.setColor(bgCircleColor); + + paint.setStyle(Paint.Style.FILL_AND_STROKE); + + canvas.drawCircle(touchStartX, touchStartY, radius_complete, paint); + + paint.setStyle(Paint.Style.STROKE); + + paint.setColor(strokeCircleColor); + + // draw start touch point circle + canvas.drawCircle(touchStartX, touchStartY, radius_dead_zone, paint); + //paint.setColor(Color.RED); + // line from start point to current touch point +// canvas.drawLine(touchStartX, touchStartY, position_stick_x, position_stick_y, paint); + + //paint.setColor(pressedColor); + canvas.drawCircle(position_stick_x, position_stick_y, radius_analog_stick, paint); +// float distance = (float) Math.sqrt(Math.pow(touchStartY - position_stick_y, 2) + Math.pow(touchStartX - position_stick_x, 2)); + +// canvas.drawCircle(touchStartX, touchStartY, touchMaxDistance, paint); + break; + } + } + } + } + + private int bgCircleColor=0x2BF5F5F9; + private int strokeCircleColor=0xFF8F8F8F; + public void setBgOpacity() { + int hexOpacity = PreferenceConfiguration.readPreferences(getContext()).senableNewAnalogStickOpacity* 255 / 100; + this.bgCircleColor = (hexOpacity << 24) | (bgCircleColor & 0x00FFFFFF); + this.strokeCircleColor = (hexOpacity << 24) | (pressedColor & 0x00FFFFFF); + invalidate(); + } + @Override + public void setOpacity(int opacity) { + super.setOpacity(opacity); + setBgOpacity(); + } + + private void updatePosition(long eventTime) { + float complete = radius_complete - radius_analog_stick; + + // calculate relative way + float correlated_y = (float) (Math.sin(Math.PI / 2 - movement_angle) * (movement_radius)); + float correlated_x = (float) (Math.cos(Math.PI / 2 - movement_angle) * (movement_radius)); + + // update positions + position_stick_x = touchStartX - correlated_x; + position_stick_y = touchStartY - correlated_y; + + // Stay active even if we're back in the deadzone because we know the user is actively + // giving analog stick input and we don't want to snap back into the deadzone. + // We also release the deadzone if the user keeps the stick pressed for a bit to allow + // them to make precise movements. + stick_state = (stick_state == AnalogStickFree.STICK_STATE.MOVED_ACTIVE || + eventTime - timeLastClick > timeoutDeadzone || + movement_radius > radius_dead_zone) ? + AnalogStickFree.STICK_STATE.MOVED_ACTIVE : AnalogStickFree.STICK_STATE.MOVED_IN_DEAD_ZONE; + + // trigger move event if state active + if (stick_state == AnalogStickFree.STICK_STATE.MOVED_ACTIVE) { + notifyOnMovement(-correlated_x / complete, correlated_y / complete); + } + } + + @Override + public boolean onElementTouchEvent(MotionEvent event) { + // save last click state + CLICK_STATE lastClickState = click_state; + + // get absolute way for each axis + relative_x = -(touchStartX - event.getX()); + relative_y = -(touchStartY - event.getY()); + + // get radius and angel of movement from center + movement_radius = getMovementRadius(relative_x, relative_y); + movement_angle = getAngle(relative_x, relative_y); + + // pass touch event to parent if out of outer circle +// if (movement_radius > radius_complete && !isPressed()) +// return false; + + // chop radius if out of outer circle or near the edge + if (movement_radius > (radius_complete - radius_analog_stick)) { + movement_radius = radius_complete - radius_analog_stick; + } + // handle event depending on action + switch (event.getActionMasked()) { + // down event (touch event) + case MotionEvent.ACTION_DOWN: + case MotionEvent.ACTION_POINTER_DOWN: { + if (!bIsFingerOnScreen) { + touchID = event.getPointerId(event.getActionIndex()); + touchStartX = event.getX(); + touchStartY = event.getY(); + bIsFingerOnScreen = true; + } + + if (touchID == event.getPointerId(event.getActionIndex())) { + touchX = event.getX(); + touchY = event.getY(); + + // set to dead zoned, will be corrected in update position if necessary + stick_state = STICK_STATE.MOVED_IN_DEAD_ZONE; + // check for double click + if (lastClickState == CLICK_STATE.SINGLE && + timeLastClick + timeoutDoubleClick > System.currentTimeMillis()) { + click_state = CLICK_STATE.DOUBLE; + notifyOnDoubleClick(); + } else { + click_state = CLICK_STATE.SINGLE; + notifyOnClick(); + } + // reset last click timestamp + timeLastClick = System.currentTimeMillis(); + // set item pressed and update + setPressed(true); + + updatePosition(event.getEventTime()); + } + break; + } + case MotionEvent.ACTION_MOVE: { + for (int i = 0; i < event.getPointerCount(); i++) { + if (touchID == event.getPointerId(i)) { + touchX = event.getX(); + touchY = event.getY(); + + updatePosition(event.getEventTime()); + } + } + break; + } + // up event (revoke touch) + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_POINTER_UP: { + if (touchID == event.getPointerId(event.getActionIndex())) { + setPressed(false); + bIsFingerOnScreen = false; + } + break; + } + } + + if (isPressed()) { + updatePosition(event.getEventTime()); + // when is pressed calculate new positions (will trigger movement if necessary) + } else { + stick_state = STICK_STATE.NO_MOVEMENT; + notifyOnRevoke(); + + // not longer pressed reset analog stick + notifyOnMovement(0, 0); + } + // refresh view + invalidate(); + // accept the touch event + return true; + } + + +// @Override +// public boolean onElementTouchEvent(MotionEvent event) { +// // save last click state +// AnalogStick2.CLICK_STATE lastClickState = click_state; +// +// // get absolute way for each axis +// relative_x = -(getWidth() / 2 - event.getX()); +// relative_y = -(getHeight() / 2 - event.getY()); +// +// // get radius and angel of movement from center +// movement_radius = getMovementRadius(relative_x, relative_y); +// movement_angle = getAngle(relative_x, relative_y); +// +// // pass touch event to parent if out of outer circle +// if (movement_radius > radius_complete && !isPressed()) +// return false; +// +// // chop radius if out of outer circle or near the edge +// if (movement_radius > (radius_complete - radius_analog_stick)) { +// movement_radius = radius_complete - radius_analog_stick; +// } +// +// // handle event depending on action +// switch (event.getActionMasked()) { +// // down event (touch event) +// case MotionEvent.ACTION_DOWN: { +// // set to dead zoned, will be corrected in update position if necessary +// stick_state = AnalogStick2.STICK_STATE.MOVED_IN_DEAD_ZONE; +// // check for double click +// if (lastClickState == AnalogStick2.CLICK_STATE.SINGLE && +// event.getEventTime() - timeLastClick <= timeoutDoubleClick) { +// click_state = AnalogStick2.CLICK_STATE.DOUBLE; +// notifyOnDoubleClick(); +// } else { +// click_state = AnalogStick2.CLICK_STATE.SINGLE; +// notifyOnClick(); +// } +// // reset last click timestamp +// timeLastClick = event.getEventTime(); +// // set item pressed and update +// setPressed(true); +// break; +// } +// // up event (revoke touch) +// case MotionEvent.ACTION_CANCEL: +// case MotionEvent.ACTION_UP: { +// setPressed(false); +// break; +// } +// } +// +// if (isPressed()) { +// // when is pressed calculate new positions (will trigger movement if necessary) +// updatePosition(event.getEventTime()); +// } else { +// stick_state = AnalogStick2.STICK_STATE.NO_MOVEMENT; +// notifyOnRevoke(); +// +// // not longer pressed reset analog stick +// notifyOnMovement(0, 0); +// } +// // refresh view +// invalidate(); +// // accept the touch event +// return true; +// } +} diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/DigitalButton.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/DigitalButton.java old mode 100644 new mode 100755 index e24484b096..ecc7258a64 --- a/app/src/main/java/com/limelight/binding/input/virtual_controller/DigitalButton.java +++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/DigitalButton.java @@ -1,233 +1,267 @@ -/** - * Created by Karim Mreisi. - */ - -package com.limelight.binding.input.virtual_controller; - -import android.content.Context; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.Paint; -import android.graphics.RectF; -import android.graphics.drawable.Drawable; -import android.view.MotionEvent; - -import java.util.ArrayList; -import java.util.List; - -/** - * This is a digital button on screen element. It is used to get click and double click user input. - */ -public class DigitalButton extends VirtualControllerElement { - - /** - * Listener interface to update registered observers. - */ - public interface DigitalButtonListener { - - /** - * onClick event will be fired on button click. - */ - void onClick(); - - /** - * onLongClick event will be fired on button long click. - */ - void onLongClick(); - - /** - * onRelease event will be fired on button unpress. - */ - void onRelease(); - } - - private List listeners = new ArrayList<>(); - private String text = ""; - private int icon = -1; - private long timerLongClickTimeout = 3000; - private final Runnable longClickRunnable = new Runnable() { - @Override - public void run() { - onLongClickCallback(); - } - }; - - private final Paint paint = new Paint(); - private final RectF rect = new RectF(); - - private int layer; - private DigitalButton movingButton = null; - - boolean inRange(float x, float y) { - return (this.getX() < x && this.getX() + this.getWidth() > x) && - (this.getY() < y && this.getY() + this.getHeight() > y); - } - - public boolean checkMovement(float x, float y, DigitalButton movingButton) { - // check if the movement happened in the same layer - if (movingButton.layer != this.layer) { - return false; - } - - // save current pressed state - boolean wasPressed = isPressed(); - - // check if the movement directly happened on the button - if ((this.movingButton == null || movingButton == this.movingButton) - && this.inRange(x, y)) { - // set button pressed state depending on moving button pressed state - if (this.isPressed() != movingButton.isPressed()) { - this.setPressed(movingButton.isPressed()); - } - } - // check if the movement is outside of the range and the movement button - // is the saved moving button - else if (movingButton == this.movingButton) { - this.setPressed(false); - } - - // check if a change occurred - if (wasPressed != isPressed()) { - if (isPressed()) { - // is pressed set moving button and emit click event - this.movingButton = movingButton; - onClickCallback(); - } else { - // no longer pressed reset moving button and emit release event - this.movingButton = null; - onReleaseCallback(); - } - - invalidate(); - - return true; - } - - return false; - } - - private void checkMovementForAllButtons(float x, float y) { - for (VirtualControllerElement element : virtualController.getElements()) { - if (element != this && element instanceof DigitalButton) { - ((DigitalButton) element).checkMovement(x, y, this); - } - } - } - - public DigitalButton(VirtualController controller, int elementId, int layer, Context context) { - super(controller, context, elementId); - this.layer = layer; - } - - public void addDigitalButtonListener(DigitalButtonListener listener) { - listeners.add(listener); - } - - public void setText(String text) { - this.text = text; - invalidate(); - } - - public void setIcon(int id) { - this.icon = id; - invalidate(); - } - - @Override - protected void onElementDraw(Canvas canvas) { - // set transparent background - canvas.drawColor(Color.TRANSPARENT); - - paint.setTextSize(getPercent(getWidth(), 25)); - paint.setTextAlign(Paint.Align.CENTER); - paint.setStrokeWidth(getDefaultStrokeWidth()); - - paint.setColor(isPressed() ? pressedColor : getDefaultColor()); - paint.setStyle(Paint.Style.STROKE); - - rect.left = rect.top = paint.getStrokeWidth(); - rect.right = getWidth() - rect.left; - rect.bottom = getHeight() - rect.top; - - canvas.drawOval(rect, paint); - - if (icon != -1) { - Drawable d = getResources().getDrawable(icon); - d.setBounds(5, 5, getWidth() - 5, getHeight() - 5); - d.draw(canvas); - } else { - paint.setStyle(Paint.Style.FILL_AND_STROKE); - paint.setStrokeWidth(getDefaultStrokeWidth()/2); - canvas.drawText(text, getPercent(getWidth(), 50), getPercent(getHeight(), 63), paint); - } - } - - private void onClickCallback() { - _DBG("clicked"); - // notify listeners - for (DigitalButtonListener listener : listeners) { - listener.onClick(); - } - - virtualController.getHandler().removeCallbacks(longClickRunnable); - virtualController.getHandler().postDelayed(longClickRunnable, timerLongClickTimeout); - } - - private void onLongClickCallback() { - _DBG("long click"); - // notify listeners - for (DigitalButtonListener listener : listeners) { - listener.onLongClick(); - } - } - - private void onReleaseCallback() { - _DBG("released"); - // notify listeners - for (DigitalButtonListener listener : listeners) { - listener.onRelease(); - } - - // We may be called for a release without a prior click - virtualController.getHandler().removeCallbacks(longClickRunnable); - } - - @Override - public boolean onElementTouchEvent(MotionEvent event) { - // get masked (not specific to a pointer) action - float x = getX() + event.getX(); - float y = getY() + event.getY(); - int action = event.getActionMasked(); - - switch (action) { - case MotionEvent.ACTION_DOWN: { - movingButton = null; - setPressed(true); - onClickCallback(); - - invalidate(); - - return true; - } - case MotionEvent.ACTION_MOVE: { - checkMovementForAllButtons(x, y); - - return true; - } - case MotionEvent.ACTION_CANCEL: - case MotionEvent.ACTION_UP: { - setPressed(false); - onReleaseCallback(); - - checkMovementForAllButtons(x, y); - - invalidate(); - - return true; - } - default: { - } - } - return true; - } -} +/** + * Created by Karim Mreisi. + */ + +package com.limelight.binding.input.virtual_controller; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.RectF; +import android.graphics.drawable.Drawable; +import android.view.MotionEvent; + +import com.limelight.preferences.PreferenceConfiguration; + +import java.util.ArrayList; +import java.util.List; + +/** + * This is a digital button on screen element. It is used to get click and double click user input. + */ +public class DigitalButton extends VirtualControllerElement { + + /** + * Listener interface to update registered observers. + */ + public interface DigitalButtonListener { + + /** + * onClick event will be fired on button click. + */ + void onClick(); + + /** + * onLongClick event will be fired on button long click. + */ + void onLongClick(); + + /** + * onRelease event will be fired on button unpress. + */ + void onRelease(); + } + + private List listeners = new ArrayList<>(); + private String text = ""; + private int icon = -1; + + private int iconPress=-1; + private long timerLongClickTimeout = 3000; + private final Runnable longClickRunnable = new Runnable() { + @Override + public void run() { + onLongClickCallback(); + } + }; + + private final Paint paint = new Paint(); + private final RectF rect = new RectF(); + + private int layer; + private DigitalButton movingButton = null; + + boolean inRange(float x, float y) { + return (this.getX() < x && this.getX() + this.getWidth() > x) && + (this.getY() < y && this.getY() + this.getHeight() > y); + } + + public boolean checkMovement(float x, float y, DigitalButton movingButton) { + // check if the movement happened in the same layer + if (movingButton.layer != this.layer) { + return false; + } + + // save current pressed state + boolean wasPressed = isPressed(); + + // check if the movement directly happened on the button + if ((this.movingButton == null || movingButton == this.movingButton) + && this.inRange(x, y)) { + // set button pressed state depending on moving button pressed state + if (this.isPressed() != movingButton.isPressed()) { + this.setPressed(movingButton.isPressed()); + } + } + // check if the movement is outside of the range and the movement button + // is the saved moving button + else if (movingButton == this.movingButton) { + this.setPressed(false); + } + + // check if a change occurred + if (wasPressed != isPressed()) { + if (isPressed()) { + // is pressed set moving button and emit click event + this.movingButton = movingButton; + onClickCallback(); + } else { + // no longer pressed reset moving button and emit release event + this.movingButton = null; + onReleaseCallback(); + } + + invalidate(); + + return true; + } + + return false; + } + + private void checkMovementForAllButtons(float x, float y) { + for (VirtualControllerElement element : virtualController.getElements()) { + if (element != this && element instanceof DigitalButton) { + ((DigitalButton) element).checkMovement(x, y, this); + } + } + } + + public DigitalButton(VirtualController controller, int elementId, int layer, Context context) { + super(controller, context, elementId); + this.layer = layer; + } + + public void addDigitalButtonListener(DigitalButtonListener listener) { + listeners.add(listener); + } + + public void setText(String text) { + this.text = text; + invalidate(); + } + + public void setIcon(int id) { + this.icon = id; + invalidate(); + } + + public void setIconPress(int iconPress) { + this.iconPress = iconPress; + } + + @Override + protected void onElementDraw(Canvas canvas) { + // set transparent background + canvas.drawColor(Color.TRANSPARENT); + + paint.setTextSize(getPercent(getWidth(), 25)); + + paint.setTextAlign(Paint.Align.CENTER); + + paint.setStrokeWidth(getDefaultStrokeWidth()); + + paint.setColor(isPressed() ? pressedColor:getDefaultColor()); + + rect.left = rect.top = paint.getStrokeWidth(); + rect.right = getWidth() - rect.left; + rect.bottom = getHeight() - rect.top; + + //皮肤选择 官方皮肤 + if(PreferenceConfiguration.readPreferences(getContext()).enableOnScreenStyleOfficial){ + paint.setStyle(Paint.Style.STROKE); + //方形 + if(PreferenceConfiguration.readPreferences(getContext()).enableKeyboardSquare){ + canvas.drawRect(rect,paint); + }else{ + canvas.drawOval(rect, paint); + } + paint.setStyle(Paint.Style.FILL_AND_STROKE); + paint.setStrokeWidth(getDefaultStrokeWidth()/2); + canvas.drawText(text, getPercent(getWidth(), 50), getPercent(getHeight(), 63), paint); + return; + } + int oscOpacity=PreferenceConfiguration.readPreferences(getContext()).oscOpacity; + //虚拟手柄皮肤 + if (icon != -1) { + Drawable d = getResources().getDrawable(isPressed()?iconPress:icon); + d.setBounds(5, 5, getWidth() - 5, getHeight() - 5); + d.setAlpha((int) (oscOpacity*2.55)); + d.draw(canvas); + }else{ + paint.setStyle(Paint.Style.STROKE); + paint.setStrokeWidth(getDefaultStrokeWidth()/2); + canvas.drawText(text, getPercent(getWidth(), 50), getPercent(getHeight(), 63), paint); + } + + boolean bIsMoving = virtualController.getControllerMode() == VirtualController.ControllerMode.MoveButtons; + boolean bIsResizing = virtualController.getControllerMode() == VirtualController.ControllerMode.ResizeButtons; + boolean bIsEnable = virtualController.getControllerMode() == VirtualController.ControllerMode.DisableEnableButtons; + + if (bIsMoving || bIsResizing || bIsEnable ||icon==-1) { + paint.setStyle(Paint.Style.STROKE); + canvas.drawRect(rect,paint); + } + + } + + private void onClickCallback() { + _DBG("clicked"); + // notify listeners + for (DigitalButtonListener listener : listeners) { + listener.onClick(); + } + + virtualController.getHandler().removeCallbacks(longClickRunnable); + virtualController.getHandler().postDelayed(longClickRunnable, timerLongClickTimeout); + } + + private void onLongClickCallback() { + _DBG("long click"); + // notify listeners + for (DigitalButtonListener listener : listeners) { + listener.onLongClick(); + } + } + + private void onReleaseCallback() { + _DBG("released"); + // notify listeners + for (DigitalButtonListener listener : listeners) { + listener.onRelease(); + } + + // We may be called for a release without a prior click + virtualController.getHandler().removeCallbacks(longClickRunnable); + } + + @Override + public boolean onElementTouchEvent(MotionEvent event) { + // get masked (not specific to a pointer) action + float x = getX() + event.getX(); + float y = getY() + event.getY(); + int action = event.getActionMasked(); + + switch (action) { + case MotionEvent.ACTION_DOWN: { + movingButton = null; + setPressed(true); + onClickCallback(); + + invalidate(); + + return true; + } + case MotionEvent.ACTION_MOVE: { + checkMovementForAllButtons(x, y); + + return true; + } + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: { + setPressed(false); + onReleaseCallback(); + + checkMovementForAllButtons(x, y); + + invalidate(); + + return true; + } + default: { + } + } + return true; + } +} diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/DigitalPad.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/DigitalPad.java old mode 100644 new mode 100755 index 1f3d9fed8c..33f4d1e4d6 --- a/app/src/main/java/com/limelight/binding/input/virtual_controller/DigitalPad.java +++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/DigitalPad.java @@ -1,203 +1,316 @@ -/** - * Created by Karim Mreisi. - */ - -package com.limelight.binding.input.virtual_controller; - -import android.content.Context; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.Paint; -import android.view.MotionEvent; - -import java.util.ArrayList; -import java.util.List; - -public class DigitalPad extends VirtualControllerElement { - public final static int DIGITAL_PAD_DIRECTION_NO_DIRECTION = 0; - int direction = DIGITAL_PAD_DIRECTION_NO_DIRECTION; - public final static int DIGITAL_PAD_DIRECTION_LEFT = 1; - public final static int DIGITAL_PAD_DIRECTION_UP = 2; - public final static int DIGITAL_PAD_DIRECTION_RIGHT = 4; - public final static int DIGITAL_PAD_DIRECTION_DOWN = 8; - List listeners = new ArrayList<>(); - - private static final int DPAD_MARGIN = 5; - - private final Paint paint = new Paint(); - - public DigitalPad(VirtualController controller, Context context) { - super(controller, context, EID_DPAD); - } - - public void addDigitalPadListener(DigitalPadListener listener) { - listeners.add(listener); - } - - @Override - protected void onElementDraw(Canvas canvas) { - // set transparent background - canvas.drawColor(Color.TRANSPARENT); - - paint.setTextSize(getPercent(getCorrectWidth(), 20)); - paint.setTextAlign(Paint.Align.CENTER); - paint.setStrokeWidth(getDefaultStrokeWidth()); - - if (direction == DIGITAL_PAD_DIRECTION_NO_DIRECTION) { - // draw no direction rect - paint.setStyle(Paint.Style.STROKE); - paint.setColor(getDefaultColor()); - canvas.drawRect( - getPercent(getWidth(), 36), getPercent(getHeight(), 36), - getPercent(getWidth(), 63), getPercent(getHeight(), 63), - paint - ); - } - - // draw left rect - paint.setColor( - (direction & DIGITAL_PAD_DIRECTION_LEFT) > 0 ? pressedColor : getDefaultColor()); - paint.setStyle(Paint.Style.STROKE); - canvas.drawRect( - paint.getStrokeWidth()+DPAD_MARGIN, getPercent(getHeight(), 33), - getPercent(getWidth(), 33), getPercent(getHeight(), 66), - paint - ); - - - // draw up rect - paint.setColor( - (direction & DIGITAL_PAD_DIRECTION_UP) > 0 ? pressedColor : getDefaultColor()); - paint.setStyle(Paint.Style.STROKE); - canvas.drawRect( - getPercent(getWidth(), 33), paint.getStrokeWidth()+DPAD_MARGIN, - getPercent(getWidth(), 66), getPercent(getHeight(), 33), - paint - ); - - // draw right rect - paint.setColor( - (direction & DIGITAL_PAD_DIRECTION_RIGHT) > 0 ? pressedColor : getDefaultColor()); - paint.setStyle(Paint.Style.STROKE); - canvas.drawRect( - getPercent(getWidth(), 66), getPercent(getHeight(), 33), - getWidth() - (paint.getStrokeWidth()+DPAD_MARGIN), getPercent(getHeight(), 66), - paint - ); - - // draw down rect - paint.setColor( - (direction & DIGITAL_PAD_DIRECTION_DOWN) > 0 ? pressedColor : getDefaultColor()); - paint.setStyle(Paint.Style.STROKE); - canvas.drawRect( - getPercent(getWidth(), 33), getPercent(getHeight(), 66), - getPercent(getWidth(), 66), getHeight() - (paint.getStrokeWidth()+DPAD_MARGIN), - paint - ); - - // draw left up line - paint.setColor(( - (direction & DIGITAL_PAD_DIRECTION_LEFT) > 0 && - (direction & DIGITAL_PAD_DIRECTION_UP) > 0 - ) ? pressedColor : getDefaultColor() - ); - paint.setStyle(Paint.Style.STROKE); - canvas.drawLine( - paint.getStrokeWidth()+DPAD_MARGIN, getPercent(getHeight(), 33), - getPercent(getWidth(), 33), paint.getStrokeWidth()+DPAD_MARGIN, - paint - ); - - // draw up right line - paint.setColor(( - (direction & DIGITAL_PAD_DIRECTION_UP) > 0 && - (direction & DIGITAL_PAD_DIRECTION_RIGHT) > 0 - ) ? pressedColor : getDefaultColor() - ); - paint.setStyle(Paint.Style.STROKE); - canvas.drawLine( - getPercent(getWidth(), 66), paint.getStrokeWidth()+DPAD_MARGIN, - getWidth() - (paint.getStrokeWidth()+DPAD_MARGIN), getPercent(getHeight(), 33), - paint - ); - - // draw right down line - paint.setColor(( - (direction & DIGITAL_PAD_DIRECTION_RIGHT) > 0 && - (direction & DIGITAL_PAD_DIRECTION_DOWN) > 0 - ) ? pressedColor : getDefaultColor() - ); - paint.setStyle(Paint.Style.STROKE); - canvas.drawLine( - getWidth()-paint.getStrokeWidth(), getPercent(getHeight(), 66), - getPercent(getWidth(), 66), getHeight()-(paint.getStrokeWidth()+DPAD_MARGIN), - paint - ); - - // draw down left line - paint.setColor(( - (direction & DIGITAL_PAD_DIRECTION_DOWN) > 0 && - (direction & DIGITAL_PAD_DIRECTION_LEFT) > 0 - ) ? pressedColor : getDefaultColor() - ); - paint.setStyle(Paint.Style.STROKE); - canvas.drawLine( - getPercent(getWidth(), 33), getHeight()-(paint.getStrokeWidth()+DPAD_MARGIN), - paint.getStrokeWidth()+DPAD_MARGIN, getPercent(getHeight(), 66), - paint - ); - } - - private void newDirectionCallback(int direction) { - _DBG("direction: " + direction); - - // notify listeners - for (DigitalPadListener listener : listeners) { - listener.onDirectionChange(direction); - } - } - - @Override - public boolean onElementTouchEvent(MotionEvent event) { - // get masked (not specific to a pointer) action - switch (event.getActionMasked()) { - case MotionEvent.ACTION_DOWN: - case MotionEvent.ACTION_MOVE: { - direction = 0; - - if (event.getX() < getPercent(getWidth(), 33)) { - direction |= DIGITAL_PAD_DIRECTION_LEFT; - } - if (event.getX() > getPercent(getWidth(), 66)) { - direction |= DIGITAL_PAD_DIRECTION_RIGHT; - } - if (event.getY() > getPercent(getHeight(), 66)) { - direction |= DIGITAL_PAD_DIRECTION_DOWN; - } - if (event.getY() < getPercent(getHeight(), 33)) { - direction |= DIGITAL_PAD_DIRECTION_UP; - } - newDirectionCallback(direction); - invalidate(); - - return true; - } - case MotionEvent.ACTION_CANCEL: - case MotionEvent.ACTION_UP: { - direction = 0; - newDirectionCallback(direction); - invalidate(); - - return true; - } - default: { - } - } - - return true; - } - - public interface DigitalPadListener { - void onDirectionChange(int direction); - } -} +/** + * Created by Karim Mreisi. + */ + +package com.limelight.binding.input.virtual_controller; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.RectF; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.view.MotionEvent; + +import com.limelight.LimeLog; +import com.limelight.R; +import com.limelight.preferences.PreferenceConfiguration; + +import java.util.ArrayList; +import java.util.List; + +public class DigitalPad extends VirtualControllerElement { + public final static int DIGITAL_PAD_DIRECTION_NO_DIRECTION = 0; + int direction = DIGITAL_PAD_DIRECTION_NO_DIRECTION; + public final static int DIGITAL_PAD_DIRECTION_LEFT = 1; + public final static int DIGITAL_PAD_DIRECTION_UP = 2; + public final static int DIGITAL_PAD_DIRECTION_RIGHT = 4; + public final static int DIGITAL_PAD_DIRECTION_DOWN = 8; + List listeners = new ArrayList<>(); + + private static final int DPAD_MARGIN = 5; + private final RectF rect = new RectF(); + + private final Paint paint = new Paint(); + + public DigitalPad(VirtualController controller, Context context) { + super(controller, context, EID_DPAD); + } + + public void addDigitalPadListener(DigitalPadListener listener) { + listeners.add(listener); + } + + @Override + protected void onElementDraw(Canvas canvas) { + // set transparent background + canvas.drawColor(Color.TRANSPARENT); + + paint.setTextSize(getPercent(getCorrectWidth(), 20)); + paint.setTextAlign(Paint.Align.CENTER); + paint.setStrokeWidth(getDefaultStrokeWidth()); + //虚拟手柄皮肤 yuzu + if(!PreferenceConfiguration.readPreferences(getContext()).enableOnScreenStyleOfficial) { + int oscOpacity=PreferenceConfiguration.readPreferences(getContext()).oscOpacity; + + paint.setColor(isPressed() ? pressedColor:getDefaultColor()); + rect.left = rect.top = paint.getStrokeWidth(); + rect.right = getWidth() - rect.left; + rect.bottom = getHeight() - rect.top; + + boolean bIsMoving = virtualController.getControllerMode() == VirtualController.ControllerMode.MoveButtons; + boolean bIsResizing = virtualController.getControllerMode() == VirtualController.ControllerMode.ResizeButtons; + boolean bIsEnable = virtualController.getControllerMode() == VirtualController.ControllerMode.DisableEnableButtons; + + if (bIsMoving || bIsResizing || bIsEnable) { + paint.setStyle(Paint.Style.STROKE); + canvas.drawRect(rect,paint); + } + + if (direction == DIGITAL_PAD_DIRECTION_NO_DIRECTION) { + Drawable d = getResources().getDrawable(R.drawable.facebutton_dpad); + d.setBounds(5, 5, getWidth() - 5, getHeight() - 5); + d.setAlpha((int) (oscOpacity*2.55)); + d.draw(canvas); + } + + if (direction == DIGITAL_PAD_DIRECTION_UP) { + Drawable d = getResources().getDrawable(R.drawable.facebutton_dpad_up); + d.setBounds(5, 5, getWidth() - 5, getHeight() - 5); + d.setAlpha((int) (oscOpacity*2.55)); + d.draw(canvas); + } + + if (direction == DIGITAL_PAD_DIRECTION_DOWN) { + Drawable d = getResources().getDrawable(R.drawable.facebutton_dpad_up); + Drawable newD=rotateDrawable(d,180); + newD.setBounds(5, 5, getWidth() - 5, getHeight() - 5); + newD.setAlpha((int) (oscOpacity*2.55)); + newD.draw(canvas); + } + + if (direction == DIGITAL_PAD_DIRECTION_LEFT) { + Drawable d = getResources().getDrawable(R.drawable.facebutton_dpad_up); + Drawable newD=rotateDrawable(d,270); + newD.setBounds(5, 5, getWidth() - 5, getHeight() - 5); + newD.setAlpha((int) (oscOpacity*2.55)); + newD.draw(canvas); + } + + if (direction == DIGITAL_PAD_DIRECTION_RIGHT) { + Drawable d = getResources().getDrawable(R.drawable.facebutton_dpad_up); + Drawable newD=rotateDrawable(d,90); + newD.setBounds(5, 5, getWidth() - 5, getHeight() - 5); + newD.setAlpha((int) (oscOpacity*2.55)); + newD.draw(canvas); + } + //right up + if((direction & DIGITAL_PAD_DIRECTION_RIGHT) > 0 && (direction & DIGITAL_PAD_DIRECTION_UP) > 0){ + Drawable d = getResources().getDrawable(R.drawable.facebutton_dpad_up_right); + Drawable newD=rotateDrawable(d,90); + newD.setBounds(5, 5, getWidth() - 5, getHeight() - 5); + newD.setAlpha((int) (oscOpacity*2.55)); + newD.draw(canvas); + } + + if((direction & DIGITAL_PAD_DIRECTION_LEFT) > 0 && (direction & DIGITAL_PAD_DIRECTION_UP) > 0){ + Drawable d = getResources().getDrawable(R.drawable.facebutton_dpad_up_right); + d.setBounds(5, 5, getWidth() - 5, getHeight() - 5); + d.setAlpha((int) (oscOpacity*2.55)); + d.draw(canvas); + } + + if((direction & DIGITAL_PAD_DIRECTION_RIGHT) > 0 && (direction & DIGITAL_PAD_DIRECTION_DOWN) > 0){ + Drawable d = getResources().getDrawable(R.drawable.facebutton_dpad_up_right); + Drawable newD=rotateDrawable(d,180); + newD.setBounds(5, 5, getWidth() - 5, getHeight() - 5); + newD.setAlpha((int) (oscOpacity*2.55)); + newD.draw(canvas); + } + + if((direction & DIGITAL_PAD_DIRECTION_LEFT) > 0 && (direction & DIGITAL_PAD_DIRECTION_DOWN) > 0){ + Drawable d = getResources().getDrawable(R.drawable.facebutton_dpad_up_right); + Drawable newD=rotateDrawable(d,270); + newD.setBounds(5, 5, getWidth() - 5, getHeight() - 5); + newD.setAlpha((int) (oscOpacity*2.55)); + newD.draw(canvas); + } + + return; + } + //官方皮肤 + if (direction == DIGITAL_PAD_DIRECTION_NO_DIRECTION) { + // draw no direction rect + paint.setStyle(Paint.Style.STROKE); + paint.setColor(getDefaultColor()); + canvas.drawRect( + getPercent(getWidth(), 36), getPercent(getHeight(), 36), + getPercent(getWidth(), 63), getPercent(getHeight(), 63), + paint + ); + } + + // draw left rect + paint.setColor( + (direction & DIGITAL_PAD_DIRECTION_LEFT) > 0 ? pressedColor : getDefaultColor()); + paint.setStyle(Paint.Style.STROKE); + canvas.drawRect( + paint.getStrokeWidth()+DPAD_MARGIN, getPercent(getHeight(), 33), + getPercent(getWidth(), 33), getPercent(getHeight(), 66), + paint + ); + + + // draw up rect + paint.setColor( + (direction & DIGITAL_PAD_DIRECTION_UP) > 0 ? pressedColor : getDefaultColor()); + paint.setStyle(Paint.Style.STROKE); + canvas.drawRect( + getPercent(getWidth(), 33), paint.getStrokeWidth()+DPAD_MARGIN, + getPercent(getWidth(), 66), getPercent(getHeight(), 33), + paint + ); + + // draw right rect + paint.setColor( + (direction & DIGITAL_PAD_DIRECTION_RIGHT) > 0 ? pressedColor : getDefaultColor()); + paint.setStyle(Paint.Style.STROKE); + canvas.drawRect( + getPercent(getWidth(), 66), getPercent(getHeight(), 33), + getWidth() - (paint.getStrokeWidth()+DPAD_MARGIN), getPercent(getHeight(), 66), + paint + ); + + // draw down rect + paint.setColor( + (direction & DIGITAL_PAD_DIRECTION_DOWN) > 0 ? pressedColor : getDefaultColor()); + paint.setStyle(Paint.Style.STROKE); + canvas.drawRect( + getPercent(getWidth(), 33), getPercent(getHeight(), 66), + getPercent(getWidth(), 66), getHeight() - (paint.getStrokeWidth()+DPAD_MARGIN), + paint + ); + + // draw left up line + paint.setColor(( + (direction & DIGITAL_PAD_DIRECTION_LEFT) > 0 && + (direction & DIGITAL_PAD_DIRECTION_UP) > 0 + ) ? pressedColor : getDefaultColor() + ); + paint.setStyle(Paint.Style.STROKE); + canvas.drawLine( + paint.getStrokeWidth()+DPAD_MARGIN, getPercent(getHeight(), 33), + getPercent(getWidth(), 33), paint.getStrokeWidth()+DPAD_MARGIN, + paint + ); + + // draw up right line + paint.setColor(( + (direction & DIGITAL_PAD_DIRECTION_UP) > 0 && + (direction & DIGITAL_PAD_DIRECTION_RIGHT) > 0 + ) ? pressedColor : getDefaultColor() + ); + paint.setStyle(Paint.Style.STROKE); + canvas.drawLine( + getPercent(getWidth(), 66), paint.getStrokeWidth()+DPAD_MARGIN, + getWidth() - (paint.getStrokeWidth()+DPAD_MARGIN), getPercent(getHeight(), 33), + paint + ); + + // draw right down line + paint.setColor(( + (direction & DIGITAL_PAD_DIRECTION_RIGHT) > 0 && + (direction & DIGITAL_PAD_DIRECTION_DOWN) > 0 + ) ? pressedColor : getDefaultColor() + ); + paint.setStyle(Paint.Style.STROKE); + canvas.drawLine( + getWidth()-paint.getStrokeWidth(), getPercent(getHeight(), 66), + getPercent(getWidth(), 66), getHeight()-(paint.getStrokeWidth()+DPAD_MARGIN), + paint + ); + + // draw down left line + paint.setColor(( + (direction & DIGITAL_PAD_DIRECTION_DOWN) > 0 && + (direction & DIGITAL_PAD_DIRECTION_LEFT) > 0 + ) ? pressedColor : getDefaultColor() + ); + paint.setStyle(Paint.Style.STROKE); + canvas.drawLine( + getPercent(getWidth(), 33), getHeight()-(paint.getStrokeWidth()+DPAD_MARGIN), + paint.getStrokeWidth()+DPAD_MARGIN, getPercent(getHeight(), 66), + paint + ); + } + + public Drawable rotateDrawable(Drawable vectorDrawable, float angle) { + int width = vectorDrawable.getIntrinsicWidth(); + int height = vectorDrawable.getIntrinsicHeight(); + Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + vectorDrawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); + vectorDrawable.draw(canvas); + + Matrix matrix = new Matrix(); + matrix.postRotate(angle); + Bitmap rotatedBitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, true); + return new BitmapDrawable(getResources(), rotatedBitmap); + } + + private void newDirectionCallback(int direction) { + _DBG("direction: " + direction); + + // notify listeners + for (DigitalPadListener listener : listeners) { + listener.onDirectionChange(direction); + } + } + + @Override + public boolean onElementTouchEvent(MotionEvent event) { + // get masked (not specific to a pointer) action + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: + case MotionEvent.ACTION_MOVE: { + direction = 0; + + if (event.getX() < getPercent(getWidth(), 33)) { + direction |= DIGITAL_PAD_DIRECTION_LEFT; + } + if (event.getX() > getPercent(getWidth(), 66)) { + direction |= DIGITAL_PAD_DIRECTION_RIGHT; + } + if (event.getY() > getPercent(getHeight(), 66)) { + direction |= DIGITAL_PAD_DIRECTION_DOWN; + } + if (event.getY() < getPercent(getHeight(), 33)) { + direction |= DIGITAL_PAD_DIRECTION_UP; + } + newDirectionCallback(direction); + invalidate(); + + return true; + } + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: { + direction = 0; + newDirectionCallback(direction); + invalidate(); + + return true; + } + default: { + } + } + + return true; + } + + public interface DigitalPadListener { + void onDirectionChange(int direction); + } +} diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/LeftAnalogStick.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/LeftAnalogStick.java old mode 100644 new mode 100755 index c8d0d5b657..60cc53b3e1 --- a/app/src/main/java/com/limelight/binding/input/virtual_controller/LeftAnalogStick.java +++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/LeftAnalogStick.java @@ -1,49 +1,49 @@ -/** - * Created by Karim Mreisi. - */ - -package com.limelight.binding.input.virtual_controller; - -import android.content.Context; - -import com.limelight.nvstream.input.ControllerPacket; - -public class LeftAnalogStick extends AnalogStick { - public LeftAnalogStick(final VirtualController controller, final Context context) { - super(controller, context, EID_LS); - - addAnalogStickListener(new AnalogStick.AnalogStickListener() { - @Override - public void onMovement(float x, float y) { - VirtualController.ControllerInputContext inputContext = - controller.getControllerInputContext(); - inputContext.leftStickX = (short) (x * 0x7FFE); - inputContext.leftStickY = (short) (y * 0x7FFE); - - controller.sendControllerInputContext(); - } - - @Override - public void onClick() { - } - - @Override - public void onDoubleClick() { - VirtualController.ControllerInputContext inputContext = - controller.getControllerInputContext(); - inputContext.inputMap |= ControllerPacket.LS_CLK_FLAG; - - controller.sendControllerInputContext(); - } - - @Override - public void onRevoke() { - VirtualController.ControllerInputContext inputContext = - controller.getControllerInputContext(); - inputContext.inputMap &= ~ControllerPacket.LS_CLK_FLAG; - - controller.sendControllerInputContext(); - } - }); - } -} +/** + * Created by Karim Mreisi. + */ + +package com.limelight.binding.input.virtual_controller; + +import android.content.Context; + +import com.limelight.nvstream.input.ControllerPacket; + +public class LeftAnalogStick extends AnalogStick { + public LeftAnalogStick(final VirtualController controller, final Context context) { + super(controller, context, EID_LS); + + addAnalogStickListener(new AnalogStick.AnalogStickListener() { + @Override + public void onMovement(float x, float y) { + VirtualController.ControllerInputContext inputContext = + controller.getControllerInputContext(); + inputContext.leftStickX = (short) (x * 0x7FFE); + inputContext.leftStickY = (short) (y * 0x7FFE); + + controller.sendControllerInputContext(); + } + + @Override + public void onClick() { + } + + @Override + public void onDoubleClick() { + VirtualController.ControllerInputContext inputContext = + controller.getControllerInputContext(); + inputContext.inputMap |= ControllerPacket.LS_CLK_FLAG; + + controller.sendControllerInputContext(); + } + + @Override + public void onRevoke() { + VirtualController.ControllerInputContext inputContext = + controller.getControllerInputContext(); + inputContext.inputMap &= ~ControllerPacket.LS_CLK_FLAG; + + controller.sendControllerInputContext(); + } + }); + } +} diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/LeftAnalogStickFree.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/LeftAnalogStickFree.java new file mode 100755 index 0000000000..d83346bd7c --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/LeftAnalogStickFree.java @@ -0,0 +1,51 @@ +/** + * Created by Karim Mreisi. + */ + +package com.limelight.binding.input.virtual_controller; + +import android.content.Context; + +import com.limelight.nvstream.input.ControllerPacket; + +public class LeftAnalogStickFree extends AnalogStickFree { + public LeftAnalogStickFree(final VirtualController controller, final Context context) { + super(controller, context, EID_LS); + + strStickSide = "L"; + + addAnalogStickListener(new AnalogStickListener() { + @Override + public void onMovement(float x, float y) { + VirtualController.ControllerInputContext inputContext = + controller.getControllerInputContext(); + inputContext.leftStickX = (short) (x * 0x7FFE); + inputContext.leftStickY = (short) (y * 0x7FFE); + + controller.sendControllerInputContext(); + } + + @Override + public void onClick() { + } + + @Override + public void onDoubleClick() { + VirtualController.ControllerInputContext inputContext = + controller.getControllerInputContext(); + inputContext.inputMap |= ControllerPacket.LS_CLK_FLAG; + + controller.sendControllerInputContext(); + } + + @Override + public void onRevoke() { + VirtualController.ControllerInputContext inputContext = + controller.getControllerInputContext(); + inputContext.inputMap &= ~ControllerPacket.LS_CLK_FLAG; + + controller.sendControllerInputContext(); + } + }); + } +} diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/LeftTrigger.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/LeftTrigger.java old mode 100644 new mode 100755 index ba71727565..0d4a819f26 --- a/app/src/main/java/com/limelight/binding/input/virtual_controller/LeftTrigger.java +++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/LeftTrigger.java @@ -1,36 +1,36 @@ -/** - * Created by Karim Mreisi. - */ - -package com.limelight.binding.input.virtual_controller; - -import android.content.Context; - -public class LeftTrigger extends DigitalButton { - public LeftTrigger(final VirtualController controller, final int layer, final Context context) { - super(controller, EID_LT, layer, context); - addDigitalButtonListener(new DigitalButton.DigitalButtonListener() { - @Override - public void onClick() { - VirtualController.ControllerInputContext inputContext = - controller.getControllerInputContext(); - inputContext.leftTrigger = (byte) 0xFF; - - controller.sendControllerInputContext(); - } - - @Override - public void onLongClick() { - } - - @Override - public void onRelease() { - VirtualController.ControllerInputContext inputContext = - controller.getControllerInputContext(); - inputContext.leftTrigger = (byte) 0x00; - - controller.sendControllerInputContext(); - } - }); - } -} +/** + * Created by Karim Mreisi. + */ + +package com.limelight.binding.input.virtual_controller; + +import android.content.Context; + +public class LeftTrigger extends DigitalButton { + public LeftTrigger(final VirtualController controller, final int layer, final Context context) { + super(controller, EID_LT, layer, context); + addDigitalButtonListener(new DigitalButton.DigitalButtonListener() { + @Override + public void onClick() { + VirtualController.ControllerInputContext inputContext = + controller.getControllerInputContext(); + inputContext.leftTrigger = (byte) 0xFF; + + controller.sendControllerInputContext(); + } + + @Override + public void onLongClick() { + } + + @Override + public void onRelease() { + VirtualController.ControllerInputContext inputContext = + controller.getControllerInputContext(); + inputContext.leftTrigger = (byte) 0x00; + + controller.sendControllerInputContext(); + } + }); + } +} diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/RightAnalogStick.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/RightAnalogStick.java old mode 100644 new mode 100755 index 91e5937e63..5cc99ae3e6 --- a/app/src/main/java/com/limelight/binding/input/virtual_controller/RightAnalogStick.java +++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/RightAnalogStick.java @@ -1,49 +1,49 @@ -/** - * Created by Karim Mreisi. - */ - -package com.limelight.binding.input.virtual_controller; - -import android.content.Context; - -import com.limelight.nvstream.input.ControllerPacket; - -public class RightAnalogStick extends AnalogStick { - public RightAnalogStick(final VirtualController controller, final Context context) { - super(controller, context, EID_RS); - - addAnalogStickListener(new AnalogStick.AnalogStickListener() { - @Override - public void onMovement(float x, float y) { - VirtualController.ControllerInputContext inputContext = - controller.getControllerInputContext(); - inputContext.rightStickX = (short) (x * 0x7FFE); - inputContext.rightStickY = (short) (y * 0x7FFE); - - controller.sendControllerInputContext(); - } - - @Override - public void onClick() { - } - - @Override - public void onDoubleClick() { - VirtualController.ControllerInputContext inputContext = - controller.getControllerInputContext(); - inputContext.inputMap |= ControllerPacket.RS_CLK_FLAG; - - controller.sendControllerInputContext(); - } - - @Override - public void onRevoke() { - VirtualController.ControllerInputContext inputContext = - controller.getControllerInputContext(); - inputContext.inputMap &= ~ControllerPacket.RS_CLK_FLAG; - - controller.sendControllerInputContext(); - } - }); - } -} +/** + * Created by Karim Mreisi. + */ + +package com.limelight.binding.input.virtual_controller; + +import android.content.Context; + +import com.limelight.nvstream.input.ControllerPacket; + +public class RightAnalogStick extends AnalogStick { + public RightAnalogStick(final VirtualController controller, final Context context) { + super(controller, context, EID_RS); + + addAnalogStickListener(new AnalogStick.AnalogStickListener() { + @Override + public void onMovement(float x, float y) { + VirtualController.ControllerInputContext inputContext = + controller.getControllerInputContext(); + inputContext.rightStickX = (short) (x * 0x7FFE); + inputContext.rightStickY = (short) (y * 0x7FFE); + + controller.sendControllerInputContext(); + } + + @Override + public void onClick() { + } + + @Override + public void onDoubleClick() { + VirtualController.ControllerInputContext inputContext = + controller.getControllerInputContext(); + inputContext.inputMap |= ControllerPacket.RS_CLK_FLAG; + + controller.sendControllerInputContext(); + } + + @Override + public void onRevoke() { + VirtualController.ControllerInputContext inputContext = + controller.getControllerInputContext(); + inputContext.inputMap &= ~ControllerPacket.RS_CLK_FLAG; + + controller.sendControllerInputContext(); + } + }); + } +} diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/RightAnalogStickFree.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/RightAnalogStickFree.java new file mode 100755 index 0000000000..dc689f8e76 --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/RightAnalogStickFree.java @@ -0,0 +1,51 @@ +/** + * Created by Karim Mreisi. + */ + +package com.limelight.binding.input.virtual_controller; + +import android.content.Context; + +import com.limelight.nvstream.input.ControllerPacket; + +public class RightAnalogStickFree extends AnalogStickFree { + public RightAnalogStickFree(final VirtualController controller, final Context context) { + super(controller, context, EID_RS); + + strStickSide = "R"; + + addAnalogStickListener(new AnalogStickListener() { + @Override + public void onMovement(float x, float y) { + VirtualController.ControllerInputContext inputContext = + controller.getControllerInputContext(); + inputContext.rightStickX = (short) (x * 0x7FFE); + inputContext.rightStickY = (short) (y * 0x7FFE); + + controller.sendControllerInputContext(); + } + + @Override + public void onClick() { + } + + @Override + public void onDoubleClick() { + VirtualController.ControllerInputContext inputContext = + controller.getControllerInputContext(); + inputContext.inputMap |= ControllerPacket.RS_CLK_FLAG; + + controller.sendControllerInputContext(); + } + + @Override + public void onRevoke() { + VirtualController.ControllerInputContext inputContext = + controller.getControllerInputContext(); + inputContext.inputMap &= ~ControllerPacket.RS_CLK_FLAG; + + controller.sendControllerInputContext(); + } + }); + } +} diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/RightTrigger.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/RightTrigger.java old mode 100644 new mode 100755 index 790ce38367..a501882074 --- a/app/src/main/java/com/limelight/binding/input/virtual_controller/RightTrigger.java +++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/RightTrigger.java @@ -1,36 +1,36 @@ -/** - * Created by Karim Mreisi. - */ - -package com.limelight.binding.input.virtual_controller; - -import android.content.Context; - -public class RightTrigger extends DigitalButton { - public RightTrigger(final VirtualController controller, final int layer, final Context context) { - super(controller, EID_RT, layer, context); - addDigitalButtonListener(new DigitalButton.DigitalButtonListener() { - @Override - public void onClick() { - VirtualController.ControllerInputContext inputContext = - controller.getControllerInputContext(); - inputContext.rightTrigger = (byte) 0xFF; - - controller.sendControllerInputContext(); - } - - @Override - public void onLongClick() { - } - - @Override - public void onRelease() { - VirtualController.ControllerInputContext inputContext = - controller.getControllerInputContext(); - inputContext.rightTrigger = (byte) 0x00; - - controller.sendControllerInputContext(); - } - }); - } -} +/** + * Created by Karim Mreisi. + */ + +package com.limelight.binding.input.virtual_controller; + +import android.content.Context; + +public class RightTrigger extends DigitalButton { + public RightTrigger(final VirtualController controller, final int layer, final Context context) { + super(controller, EID_RT, layer, context); + addDigitalButtonListener(new DigitalButton.DigitalButtonListener() { + @Override + public void onClick() { + VirtualController.ControllerInputContext inputContext = + controller.getControllerInputContext(); + inputContext.rightTrigger = (byte) 0xFF; + + controller.sendControllerInputContext(); + } + + @Override + public void onLongClick() { + } + + @Override + public void onRelease() { + VirtualController.ControllerInputContext inputContext = + controller.getControllerInputContext(); + inputContext.rightTrigger = (byte) 0x00; + + controller.sendControllerInputContext(); + } + }); + } +} diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualController.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualController.java old mode 100644 new mode 100755 index ce9f4014e8..c904ef27c5 --- a/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualController.java +++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualController.java @@ -1,215 +1,250 @@ -/** - * Created by Karim Mreisi. - */ - -package com.limelight.binding.input.virtual_controller; - -import android.content.Context; -import android.os.Handler; -import android.os.Looper; -import android.util.DisplayMetrics; -import android.view.View; -import android.widget.Button; -import android.widget.FrameLayout; -import android.widget.Toast; - -import com.limelight.LimeLog; -import com.limelight.R; -import com.limelight.binding.input.ControllerHandler; - -import java.util.ArrayList; -import java.util.List; - -public class VirtualController { - public static class ControllerInputContext { - public short inputMap = 0x0000; - public byte leftTrigger = 0x00; - public byte rightTrigger = 0x00; - public short rightStickX = 0x0000; - public short rightStickY = 0x0000; - public short leftStickX = 0x0000; - public short leftStickY = 0x0000; - } - - public enum ControllerMode { - Active, - MoveButtons, - ResizeButtons - } - - private static final boolean _PRINT_DEBUG_INFORMATION = false; - - private final ControllerHandler controllerHandler; - private final Context context; - private final Handler handler; - - private final Runnable delayedRetransmitRunnable = new Runnable() { - @Override - public void run() { - sendControllerInputContextInternal(); - } - }; - - private FrameLayout frame_layout = null; - - ControllerMode currentMode = ControllerMode.Active; - ControllerInputContext inputContext = new ControllerInputContext(); - - private Button buttonConfigure = null; - - private List elements = new ArrayList<>(); - - public VirtualController(final ControllerHandler controllerHandler, FrameLayout layout, final Context context) { - this.controllerHandler = controllerHandler; - this.frame_layout = layout; - this.context = context; - this.handler = new Handler(Looper.getMainLooper()); - - buttonConfigure = new Button(context); - buttonConfigure.setAlpha(0.25f); - buttonConfigure.setFocusable(false); - buttonConfigure.setBackgroundResource(R.drawable.ic_settings); - buttonConfigure.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - String message; - - if (currentMode == ControllerMode.Active){ - currentMode = ControllerMode.MoveButtons; - message = "Entering configuration mode (Move buttons)"; - } else if (currentMode == ControllerMode.MoveButtons) { - currentMode = ControllerMode.ResizeButtons; - message = "Entering configuration mode (Resize buttons)"; - } else { - currentMode = ControllerMode.Active; - VirtualControllerConfigurationLoader.saveProfile(VirtualController.this, context); - message = "Exiting configuration mode"; - } - - Toast.makeText(context, message, Toast.LENGTH_SHORT).show(); - - buttonConfigure.invalidate(); - - for (VirtualControllerElement element : elements) { - element.invalidate(); - } - } - }); - - } - - Handler getHandler() { - return handler; - } - - public void hide() { - for (VirtualControllerElement element : elements) { - element.setVisibility(View.INVISIBLE); - } - - buttonConfigure.setVisibility(View.INVISIBLE); - } - - public void show() { - for (VirtualControllerElement element : elements) { - element.setVisibility(View.VISIBLE); - } - - buttonConfigure.setVisibility(View.VISIBLE); - } - - public void removeElements() { - for (VirtualControllerElement element : elements) { - frame_layout.removeView(element); - } - elements.clear(); - - frame_layout.removeView(buttonConfigure); - } - - public void setOpacity(int opacity) { - for (VirtualControllerElement element : elements) { - element.setOpacity(opacity); - } - } - - - public void addElement(VirtualControllerElement element, int x, int y, int width, int height) { - elements.add(element); - FrameLayout.LayoutParams layoutParams = new FrameLayout.LayoutParams(width, height); - layoutParams.setMargins(x, y, 0, 0); - - frame_layout.addView(element, layoutParams); - } - - public List getElements() { - return elements; - } - - private static final void _DBG(String text) { - if (_PRINT_DEBUG_INFORMATION) { - LimeLog.info("VirtualController: " + text); - } - } - - public void refreshLayout() { - removeElements(); - - DisplayMetrics screen = context.getResources().getDisplayMetrics(); - - int buttonSize = (int)(screen.heightPixels*0.06f); - FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(buttonSize, buttonSize); - params.leftMargin = 15; - params.topMargin = 15; - frame_layout.addView(buttonConfigure, params); - - // Start with the default layout - VirtualControllerConfigurationLoader.createDefaultLayout(this, context); - - // Apply user preferences onto the default layout - VirtualControllerConfigurationLoader.loadFromPreferences(this, context); - } - - public ControllerMode getControllerMode() { - return currentMode; - } - - public ControllerInputContext getControllerInputContext() { - return inputContext; - } - - private void sendControllerInputContextInternal() { - _DBG("INPUT_MAP + " + inputContext.inputMap); - _DBG("LEFT_TRIGGER " + inputContext.leftTrigger); - _DBG("RIGHT_TRIGGER " + inputContext.rightTrigger); - _DBG("LEFT STICK X: " + inputContext.leftStickX + " Y: " + inputContext.leftStickY); - _DBG("RIGHT STICK X: " + inputContext.rightStickX + " Y: " + inputContext.rightStickY); - - if (controllerHandler != null) { - controllerHandler.reportOscState( - inputContext.inputMap, - inputContext.leftStickX, - inputContext.leftStickY, - inputContext.rightStickX, - inputContext.rightStickY, - inputContext.leftTrigger, - inputContext.rightTrigger - ); - } - } - - void sendControllerInputContext() { - // Cancel retransmissions of prior gamepad inputs - handler.removeCallbacks(delayedRetransmitRunnable); - - sendControllerInputContextInternal(); - - // HACK: GFE sometimes discards gamepad packets when they are received - // very shortly after another. This can be critical if an axis zeroing packet - // is lost and causes an analog stick to get stuck. To avoid this, we retransmit - // the gamepad state a few times unless another input event happens before then. - handler.postDelayed(delayedRetransmitRunnable, 25); - handler.postDelayed(delayedRetransmitRunnable, 50); - handler.postDelayed(delayedRetransmitRunnable, 75); - } -} +/** + * Created by Karim Mreisi. + */ + +package com.limelight.binding.input.virtual_controller; + +import android.content.Context; +import android.os.Handler; +import android.os.Looper; +import android.os.Vibrator; +import android.util.DisplayMetrics; +import android.view.View; +import android.widget.Button; +import android.widget.FrameLayout; +import android.widget.Toast; + +import com.limelight.LimeLog; +import com.limelight.R; +import com.limelight.binding.input.ControllerHandler; +import com.limelight.preferences.PreferenceConfiguration; + +import java.util.ArrayList; +import java.util.List; + +public class VirtualController { + public static class ControllerInputContext { +// public short inputMap = 0x0000; + public int inputMap = 0; + public byte leftTrigger = 0x00; + public byte rightTrigger = 0x00; + public short rightStickX = 0x0000; + public short rightStickY = 0x0000; + public short leftStickX = 0x0000; + public short leftStickY = 0x0000; + } + + public enum ControllerMode { + Active, + MoveButtons, + ResizeButtons, + DisableEnableButtons + } + + private static final boolean _PRINT_DEBUG_INFORMATION = false; + + private final ControllerHandler controllerHandler; + private final Context context; + private final Handler handler; + + private final Runnable delayedRetransmitRunnable = new Runnable() { + @Override + public void run() { + sendControllerInputContextInternal(); + } + }; + + private FrameLayout frame_layout = null; + + ControllerMode currentMode = ControllerMode.Active; + ControllerInputContext inputContext = new ControllerInputContext(); + + private Button buttonConfigure = null; + + private List elements = new ArrayList<>(); + + private Vibrator vibrator; + + public VirtualController(final ControllerHandler controllerHandler, FrameLayout layout, final Context context) { + this.controllerHandler = controllerHandler; + this.frame_layout = layout; + this.context = context; + this.handler = new Handler(Looper.getMainLooper()); + + this.vibrator = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE); + + buttonConfigure = new Button(context); + buttonConfigure.setAlpha(0.25f); + buttonConfigure.setFocusable(false); + buttonConfigure.setBackgroundResource(R.drawable.ic_settings); + buttonConfigure.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + String message; + + if (currentMode == ControllerMode.Active) { + currentMode = ControllerMode.DisableEnableButtons; + showElements(); + message = "Entering configuration mode (Disable/Enable buttons)"; + } else if (currentMode == ControllerMode.DisableEnableButtons){ + currentMode = ControllerMode.MoveButtons; + showEnabledElements(); + message = "Entering configuration mode (Move buttons)"; + } else if (currentMode == ControllerMode.MoveButtons) { + currentMode = ControllerMode.ResizeButtons; + message = "Entering configuration mode (Resize buttons)"; + } else { + currentMode = ControllerMode.Active; + VirtualControllerConfigurationLoader.saveProfile(VirtualController.this, context); + message = "Exiting configuration mode"; + } + + Toast.makeText(context, message, Toast.LENGTH_SHORT).show(); + + buttonConfigure.invalidate(); + + for (VirtualControllerElement element : elements) { + element.invalidate(); + } + } + }); + + } + + Handler getHandler() { + return handler; + } + + public void hide() { + for (VirtualControllerElement element : elements) { + element.setVisibility(View.GONE); + } + + buttonConfigure.setVisibility(View.GONE); + } + + public void show() { + showEnabledElements(); + + buttonConfigure.setVisibility(View.VISIBLE); + } + + public int switchShowHide() { + if (buttonConfigure.getVisibility() == View.VISIBLE) { + hide(); + return 0; + } else { + show(); + return 1; + } + } + + public void showElements(){ + for(VirtualControllerElement element : elements){ + element.setVisibility(View.VISIBLE); + } + } + + public void showEnabledElements(){ + for(VirtualControllerElement element: elements){ + element.setVisibility( element.enabled ? View.VISIBLE : View.GONE ); + } + } + + public void removeElements() { + for (VirtualControllerElement element : elements) { + frame_layout.removeView(element); + } + elements.clear(); + + frame_layout.removeView(buttonConfigure); + } + + public void setOpacity(int opacity) { + for (VirtualControllerElement element : elements) { + element.setOpacity(opacity); + } + } + + + public void addElement(VirtualControllerElement element, int x, int y, int width, int height) { + elements.add(element); + FrameLayout.LayoutParams layoutParams = new FrameLayout.LayoutParams(width, height); + layoutParams.setMargins(x, y, 0, 0); + + frame_layout.addView(element, layoutParams); + } + + public List getElements() { + return elements; + } + + private static final void _DBG(String text) { + if (_PRINT_DEBUG_INFORMATION) { + LimeLog.info("VirtualController: " + text); + } + } + + public void refreshLayout() { + removeElements(); + + DisplayMetrics screen = context.getResources().getDisplayMetrics(); + + int buttonSize = (int)(screen.heightPixels*0.06f); + FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(buttonSize, buttonSize); + params.leftMargin = 15; + params.topMargin = 15; + frame_layout.addView(buttonConfigure, params); + + // Start with the default layout + VirtualControllerConfigurationLoader.createDefaultLayout(this, context); + + // Apply user preferences onto the default layout + VirtualControllerConfigurationLoader.loadFromPreferences(this, context); + } + + public ControllerMode getControllerMode() { + return currentMode; + } + + public ControllerInputContext getControllerInputContext() { + return inputContext; + } + + private void sendControllerInputContextInternal() { + _DBG("INPUT_MAP + " + inputContext.inputMap); + _DBG("LEFT_TRIGGER " + inputContext.leftTrigger); + _DBG("RIGHT_TRIGGER " + inputContext.rightTrigger); + _DBG("LEFT STICK X: " + inputContext.leftStickX + " Y: " + inputContext.leftStickY); + _DBG("RIGHT STICK X: " + inputContext.rightStickX + " Y: " + inputContext.rightStickY); + + if (controllerHandler != null) { + controllerHandler.reportOscState( + inputContext.inputMap, + inputContext.leftStickX, + inputContext.leftStickY, + inputContext.rightStickX, + inputContext.rightStickY, + inputContext.leftTrigger, + inputContext.rightTrigger + ); + } + } + + public void sendControllerInputContext() { + // Cancel retransmissions of prior gamepad inputs + handler.removeCallbacks(delayedRetransmitRunnable); + + sendControllerInputContextInternal(); + if (PreferenceConfiguration.readPreferences(context).enableKeyboardVibrate && vibrator.hasVibrator()) { + vibrator.vibrate(10); + } + // HACK: GFE sometimes discards gamepad packets when they are received + // very shortly after another. This can be critical if an axis zeroing packet + // is lost and causes an analog stick to get stuck. To avoid this, we retransmit + // the gamepad state a few times unless another input event happens before then. + handler.postDelayed(delayedRetransmitRunnable, 25); + handler.postDelayed(delayedRetransmitRunnable, 50); + handler.postDelayed(delayedRetransmitRunnable, 75); + } +} diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualControllerConfigurationLoader.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualControllerConfigurationLoader.java old mode 100644 new mode 100755 index 55e438f75f..38db9a1f5f --- a/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualControllerConfigurationLoader.java +++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualControllerConfigurationLoader.java @@ -1,374 +1,434 @@ -/** - * Created by Karim Mreisi. - */ - -package com.limelight.binding.input.virtual_controller; - -import android.app.Activity; -import android.content.Context; -import android.content.SharedPreferences; -import android.util.DisplayMetrics; - -import com.limelight.nvstream.input.ControllerPacket; -import com.limelight.preferences.PreferenceConfiguration; - -import org.json.JSONException; -import org.json.JSONObject; - -public class VirtualControllerConfigurationLoader { - public static final String OSC_PREFERENCE = "OSC"; - - private static int getPercent( - int percent, - int total) { - return (int) (((float) total / (float) 100) * (float) percent); - } - - // The default controls are specified using a grid of 128*72 cells at 16:9 - private static int screenScale(int units, int height) { - return (int) (((float) height / (float) 72) * (float) units); - } - - private static DigitalPad createDigitalPad( - final VirtualController controller, - final Context context) { - - DigitalPad digitalPad = new DigitalPad(controller, context); - digitalPad.addDigitalPadListener(new DigitalPad.DigitalPadListener() { - @Override - public void onDirectionChange(int direction) { - VirtualController.ControllerInputContext inputContext = - controller.getControllerInputContext(); - - if ((direction & DigitalPad.DIGITAL_PAD_DIRECTION_LEFT) != 0) { - inputContext.inputMap |= ControllerPacket.LEFT_FLAG; - } - else { - inputContext.inputMap &= ~ControllerPacket.LEFT_FLAG; - } - if ((direction & DigitalPad.DIGITAL_PAD_DIRECTION_RIGHT) != 0) { - inputContext.inputMap |= ControllerPacket.RIGHT_FLAG; - } - else { - inputContext.inputMap &= ~ControllerPacket.RIGHT_FLAG; - } - if ((direction & DigitalPad.DIGITAL_PAD_DIRECTION_UP) != 0) { - inputContext.inputMap |= ControllerPacket.UP_FLAG; - } - else { - inputContext.inputMap &= ~ControllerPacket.UP_FLAG; - } - if ((direction & DigitalPad.DIGITAL_PAD_DIRECTION_DOWN) != 0) { - inputContext.inputMap |= ControllerPacket.DOWN_FLAG; - } - else { - inputContext.inputMap &= ~ControllerPacket.DOWN_FLAG; - } - - controller.sendControllerInputContext(); - } - }); - - return digitalPad; - } - - private static DigitalButton createDigitalButton( - final int elementId, - final int keyShort, - final int keyLong, - final int layer, - final String text, - final int icon, - final VirtualController controller, - final Context context) { - DigitalButton button = new DigitalButton(controller, elementId, layer, context); - button.setText(text); - button.setIcon(icon); - - button.addDigitalButtonListener(new DigitalButton.DigitalButtonListener() { - @Override - public void onClick() { - VirtualController.ControllerInputContext inputContext = - controller.getControllerInputContext(); - inputContext.inputMap |= keyShort; - - controller.sendControllerInputContext(); - } - - @Override - public void onLongClick() { - VirtualController.ControllerInputContext inputContext = - controller.getControllerInputContext(); - inputContext.inputMap |= keyLong; - - controller.sendControllerInputContext(); - } - - @Override - public void onRelease() { - VirtualController.ControllerInputContext inputContext = - controller.getControllerInputContext(); - inputContext.inputMap &= ~keyShort; - inputContext.inputMap &= ~keyLong; - - controller.sendControllerInputContext(); - } - }); - - return button; - } - - private static DigitalButton createLeftTrigger( - final int layer, - final String text, - final int icon, - final VirtualController controller, - final Context context) { - LeftTrigger button = new LeftTrigger(controller, layer, context); - button.setText(text); - button.setIcon(icon); - return button; - } - - private static DigitalButton createRightTrigger( - final int layer, - final String text, - final int icon, - final VirtualController controller, - final Context context) { - RightTrigger button = new RightTrigger(controller, layer, context); - button.setText(text); - button.setIcon(icon); - return button; - } - - private static AnalogStick createLeftStick( - final VirtualController controller, - final Context context) { - return new LeftAnalogStick(controller, context); - } - - private static AnalogStick createRightStick( - final VirtualController controller, - final Context context) { - return new RightAnalogStick(controller, context); - } - - - private static final int TRIGGER_L_BASE_X = 1; - private static final int TRIGGER_R_BASE_X = 92; - private static final int TRIGGER_DISTANCE = 23; - private static final int TRIGGER_BASE_Y = 31; - private static final int TRIGGER_WIDTH = 12; - private static final int TRIGGER_HEIGHT = 9; - - // Face buttons are defined based on the Y button (button number 9) - private static final int BUTTON_BASE_X = 106; - private static final int BUTTON_BASE_Y = 1; - private static final int BUTTON_SIZE = 10; - - private static final int DPAD_BASE_X = 4; - private static final int DPAD_BASE_Y = 41; - private static final int DPAD_SIZE = 30; - - private static final int ANALOG_L_BASE_X = 6; - private static final int ANALOG_L_BASE_Y = 4; - private static final int ANALOG_R_BASE_X = 98; - private static final int ANALOG_R_BASE_Y = 42; - private static final int ANALOG_SIZE = 26; - - private static final int L3_R3_BASE_Y = 60; - - private static final int START_X = 83; - private static final int BACK_X = 34; - private static final int START_BACK_Y = 64; - private static final int START_BACK_WIDTH = 12; - private static final int START_BACK_HEIGHT = 7; - - public static void createDefaultLayout(final VirtualController controller, final Context context) { - - DisplayMetrics screen = context.getResources().getDisplayMetrics(); - PreferenceConfiguration config = PreferenceConfiguration.readPreferences(context); - - // Displace controls on the right by this amount of pixels to account for different aspect ratios - int rightDisplacement = screen.widthPixels - screen.heightPixels * 16 / 9; - - int height = screen.heightPixels; - - // NOTE: Some of these getPercent() expressions seem like they can be combined - // into a single call. Due to floating point rounding, this isn't actually possible. - - if (!config.onlyL3R3) - { - controller.addElement(createDigitalPad(controller, context), - screenScale(DPAD_BASE_X, height), - screenScale(DPAD_BASE_Y, height), - screenScale(DPAD_SIZE, height), - screenScale(DPAD_SIZE, height) - ); - - controller.addElement(createDigitalButton( - VirtualControllerElement.EID_A, - !config.flipFaceButtons ? ControllerPacket.A_FLAG : ControllerPacket.B_FLAG, 0, 1, - !config.flipFaceButtons ? "A" : "B", -1, controller, context), - screenScale(BUTTON_BASE_X, height) + rightDisplacement, - screenScale(BUTTON_BASE_Y + 2 * BUTTON_SIZE, height), - screenScale(BUTTON_SIZE, height), - screenScale(BUTTON_SIZE, height) - ); - - controller.addElement(createDigitalButton( - VirtualControllerElement.EID_B, - config.flipFaceButtons ? ControllerPacket.A_FLAG : ControllerPacket.B_FLAG, 0, 1, - config.flipFaceButtons ? "A" : "B", -1, controller, context), - screenScale(BUTTON_BASE_X + BUTTON_SIZE, height) + rightDisplacement, - screenScale(BUTTON_BASE_Y + BUTTON_SIZE, height), - screenScale(BUTTON_SIZE, height), - screenScale(BUTTON_SIZE, height) - ); - - controller.addElement(createDigitalButton( - VirtualControllerElement.EID_X, - !config.flipFaceButtons ? ControllerPacket.X_FLAG : ControllerPacket.Y_FLAG, 0, 1, - !config.flipFaceButtons ? "X" : "Y", -1, controller, context), - screenScale(BUTTON_BASE_X - BUTTON_SIZE, height) + rightDisplacement, - screenScale(BUTTON_BASE_Y + BUTTON_SIZE, height), - screenScale(BUTTON_SIZE, height), - screenScale(BUTTON_SIZE, height) - ); - - controller.addElement(createDigitalButton( - VirtualControllerElement.EID_Y, - config.flipFaceButtons ? ControllerPacket.X_FLAG : ControllerPacket.Y_FLAG, 0, 1, - config.flipFaceButtons ? "X" : "Y", -1, controller, context), - screenScale(BUTTON_BASE_X, height) + rightDisplacement, - screenScale(BUTTON_BASE_Y, height), - screenScale(BUTTON_SIZE, height), - screenScale(BUTTON_SIZE, height) - ); - - controller.addElement(createLeftTrigger( - 1, "LT", -1, controller, context), - screenScale(TRIGGER_L_BASE_X, height), - screenScale(TRIGGER_BASE_Y, height), - screenScale(TRIGGER_WIDTH, height), - screenScale(TRIGGER_HEIGHT, height) - ); - - controller.addElement(createRightTrigger( - 1, "RT", -1, controller, context), - screenScale(TRIGGER_R_BASE_X + TRIGGER_DISTANCE, height) + rightDisplacement, - screenScale(TRIGGER_BASE_Y, height), - screenScale(TRIGGER_WIDTH, height), - screenScale(TRIGGER_HEIGHT, height) - ); - - controller.addElement(createDigitalButton( - VirtualControllerElement.EID_LB, - ControllerPacket.LB_FLAG, 0, 1, "LB", -1, controller, context), - screenScale(TRIGGER_L_BASE_X + TRIGGER_DISTANCE, height), - screenScale(TRIGGER_BASE_Y, height), - screenScale(TRIGGER_WIDTH, height), - screenScale(TRIGGER_HEIGHT, height) - ); - - controller.addElement(createDigitalButton( - VirtualControllerElement.EID_RB, - ControllerPacket.RB_FLAG, 0, 1, "RB", -1, controller, context), - screenScale(TRIGGER_R_BASE_X, height) + rightDisplacement, - screenScale(TRIGGER_BASE_Y, height), - screenScale(TRIGGER_WIDTH, height), - screenScale(TRIGGER_HEIGHT, height) - ); - - controller.addElement(createLeftStick(controller, context), - screenScale(ANALOG_L_BASE_X, height), - screenScale(ANALOG_L_BASE_Y, height), - screenScale(ANALOG_SIZE, height), - screenScale(ANALOG_SIZE, height) - ); - - controller.addElement(createRightStick(controller, context), - screenScale(ANALOG_R_BASE_X, height) + rightDisplacement, - screenScale(ANALOG_R_BASE_Y, height), - screenScale(ANALOG_SIZE, height), - screenScale(ANALOG_SIZE, height) - ); - - controller.addElement(createDigitalButton( - VirtualControllerElement.EID_BACK, - ControllerPacket.BACK_FLAG, 0, 2, "BACK", -1, controller, context), - screenScale(BACK_X, height), - screenScale(START_BACK_Y, height), - screenScale(START_BACK_WIDTH, height), - screenScale(START_BACK_HEIGHT, height) - ); - - controller.addElement(createDigitalButton( - VirtualControllerElement.EID_START, - ControllerPacket.PLAY_FLAG, 0, 3, "START", -1, controller, context), - screenScale(START_X, height) + rightDisplacement, - screenScale(START_BACK_Y, height), - screenScale(START_BACK_WIDTH, height), - screenScale(START_BACK_HEIGHT, height) - ); - } - else { - controller.addElement(createDigitalButton( - VirtualControllerElement.EID_LSB, - ControllerPacket.LS_CLK_FLAG, 0, 1, "L3", -1, controller, context), - screenScale(TRIGGER_L_BASE_X, height), - screenScale(L3_R3_BASE_Y, height), - screenScale(TRIGGER_WIDTH, height), - screenScale(TRIGGER_HEIGHT, height) - ); - - controller.addElement(createDigitalButton( - VirtualControllerElement.EID_RSB, - ControllerPacket.RS_CLK_FLAG, 0, 1, "R3", -1, controller, context), - screenScale(TRIGGER_R_BASE_X + TRIGGER_DISTANCE, height) + rightDisplacement, - screenScale(L3_R3_BASE_Y, height), - screenScale(TRIGGER_WIDTH, height), - screenScale(TRIGGER_HEIGHT, height) - ); - } - - controller.setOpacity(config.oscOpacity); - } - - public static void saveProfile(final VirtualController controller, - final Context context) { - SharedPreferences.Editor prefEditor = context.getSharedPreferences(OSC_PREFERENCE, Activity.MODE_PRIVATE).edit(); - - for (VirtualControllerElement element : controller.getElements()) { - String prefKey = ""+element.elementId; - try { - prefEditor.putString(prefKey, element.getConfiguration().toString()); - } catch (JSONException e) { - e.printStackTrace(); - } - } - - prefEditor.apply(); - } - - public static void loadFromPreferences(final VirtualController controller, final Context context) { - SharedPreferences pref = context.getSharedPreferences(OSC_PREFERENCE, Activity.MODE_PRIVATE); - - for (VirtualControllerElement element : controller.getElements()) { - String prefKey = ""+element.elementId; - - String jsonConfig = pref.getString(prefKey, null); - if (jsonConfig != null) { - try { - element.loadConfiguration(new JSONObject(jsonConfig)); - } catch (JSONException e) { - e.printStackTrace(); - - // Remove the corrupt element from the preferences - pref.edit().remove(prefKey).apply(); - } - } - } - } -} +/** + * Created by Karim Mreisi. + */ + +package com.limelight.binding.input.virtual_controller; + +import android.app.Activity; +import android.content.Context; +import android.content.SharedPreferences; +import android.util.DisplayMetrics; + +import com.limelight.R; +import com.limelight.nvstream.input.ControllerPacket; +import com.limelight.preferences.PreferenceConfiguration; + +import org.json.JSONException; +import org.json.JSONObject; + +public class VirtualControllerConfigurationLoader { + public static final String OSC_PREFERENCE = "OSC"; + + private static int getPercent( + int percent, + int total) { + return (int) (((float) total / (float) 100) * (float) percent); + } + + // The default controls are specified using a grid of 128*72 cells at 16:9 + private static int screenScale(int units, int height) { + return (int) (((float) height / (float) 72) * (float) units); + } + + private static DigitalPad createDigitalPad( + final VirtualController controller, + final Context context) { + + DigitalPad digitalPad = new DigitalPad(controller, context); + digitalPad.addDigitalPadListener(new DigitalPad.DigitalPadListener() { + @Override + public void onDirectionChange(int direction) { + VirtualController.ControllerInputContext inputContext = + controller.getControllerInputContext(); + + if ((direction & DigitalPad.DIGITAL_PAD_DIRECTION_LEFT) != 0) { + inputContext.inputMap |= ControllerPacket.LEFT_FLAG; + } + else { + inputContext.inputMap &= ~ControllerPacket.LEFT_FLAG; + } + if ((direction & DigitalPad.DIGITAL_PAD_DIRECTION_RIGHT) != 0) { + inputContext.inputMap |= ControllerPacket.RIGHT_FLAG; + } + else { + inputContext.inputMap &= ~ControllerPacket.RIGHT_FLAG; + } + if ((direction & DigitalPad.DIGITAL_PAD_DIRECTION_UP) != 0) { + inputContext.inputMap |= ControllerPacket.UP_FLAG; + } + else { + inputContext.inputMap &= ~ControllerPacket.UP_FLAG; + } + if ((direction & DigitalPad.DIGITAL_PAD_DIRECTION_DOWN) != 0) { + inputContext.inputMap |= ControllerPacket.DOWN_FLAG; + } + else { + inputContext.inputMap &= ~ControllerPacket.DOWN_FLAG; + } + + controller.sendControllerInputContext(); + } + }); + + return digitalPad; + } + + private static DigitalButton createDigitalButton( + final int elementId, + final int keyShort, + final int keyLong, + final int layer, + final String text, + final int icon, + final int iconPress, + final VirtualController controller, + final Context context) { + DigitalButton button = new DigitalButton(controller, elementId, layer, context); + button.setText(text); + button.setIcon(icon); + button.setIconPress(iconPress); + button.addDigitalButtonListener(new DigitalButton.DigitalButtonListener() { + @Override + public void onClick() { + VirtualController.ControllerInputContext inputContext = + controller.getControllerInputContext(); + inputContext.inputMap |= keyShort; + + controller.sendControllerInputContext(); + } + + @Override + public void onLongClick() { + VirtualController.ControllerInputContext inputContext = + controller.getControllerInputContext(); + inputContext.inputMap |= keyLong; + + controller.sendControllerInputContext(); + } + + @Override + public void onRelease() { + VirtualController.ControllerInputContext inputContext = + controller.getControllerInputContext(); + inputContext.inputMap &= ~keyShort; + inputContext.inputMap &= ~keyLong; + + controller.sendControllerInputContext(); + } + }); + + return button; + } + + private static DigitalButton createLeftTrigger( + final int layer, + final String text, + final int icon, + final int iconPress, + final VirtualController controller, + final Context context) { + LeftTrigger button = new LeftTrigger(controller, layer, context); + button.setText(text); + button.setIcon(icon); + button.setIconPress(iconPress); + return button; + } + + private static DigitalButton createRightTrigger( + final int layer, + final String text, + final int icon, + final int iconPress, + final VirtualController controller, + final Context context) { + RightTrigger button = new RightTrigger(controller, layer, context); + button.setText(text); + button.setIcon(icon); + button.setIconPress(iconPress); + return button; + } + + private static AnalogStick createLeftStick( + final VirtualController controller, + final Context context) { + return new LeftAnalogStick(controller, context); + } + + private static AnalogStick createRightStick( + final VirtualController controller, + final Context context) { + return new RightAnalogStick(controller, context); + } + + private static AnalogStickFree createLeftStick2( + final VirtualController controller, + final Context context) { + return new LeftAnalogStickFree(controller, context); + } + + private static AnalogStickFree createRightStick2( + final VirtualController controller, + final Context context) { + return new RightAnalogStickFree(controller, context); + } + + + private static final int TRIGGER_L_BASE_X = 1; + private static final int TRIGGER_R_BASE_X = 92; + private static final int TRIGGER_DISTANCE = 23; + private static final int TRIGGER_BASE_Y = 31; + private static final int TRIGGER_WIDTH = 12; + private static final int TRIGGER_HEIGHT = 9; + + // Face buttons are defined based on the Y button (button number 9) + private static final int BUTTON_BASE_X = 106; + private static final int BUTTON_BASE_Y = 1; + private static final int BUTTON_SIZE = 10; + + private static final int DPAD_BASE_X = 4; + private static final int DPAD_BASE_Y = 41; + private static final int DPAD_SIZE = 30; + + private static final int ANALOG_L_BASE_X = 6; + private static final int ANALOG_L_BASE_Y = 4; + private static final int ANALOG_R_BASE_X = 98; + private static final int ANALOG_R_BASE_Y = 42; + private static final int ANALOG_SIZE = 26; + + private static final int L3_R3_BASE_Y = 60; + + private static final int START_X = 83; + private static final int BACK_X = 34; + private static final int START_BACK_Y = 64; + private static final int START_BACK_WIDTH = 12; + private static final int START_BACK_HEIGHT = 7; + + public static void createDefaultLayout(final VirtualController controller, final Context context) { + + DisplayMetrics screen = context.getResources().getDisplayMetrics(); + PreferenceConfiguration config = PreferenceConfiguration.readPreferences(context); + + // Displace controls on the right by this amount of pixels to account for different aspect ratios + int rightDisplacement = screen.widthPixels - screen.heightPixels * 16 / 9; + + int height = screen.heightPixels; + + // NOTE: Some of these getPercent() expressions seem like they can be combined + // into a single call. Due to floating point rounding, this isn't actually possible. + + if (!config.onlyL3R3) + { + controller.addElement(createDigitalPad(controller, context), + screenScale(DPAD_BASE_X, height), + screenScale(DPAD_BASE_Y, height), + screenScale(DPAD_SIZE, height), + screenScale(DPAD_SIZE, height) + ); + + controller.addElement(createDigitalButton( + VirtualControllerElement.EID_A, + !config.flipFaceButtons ? ControllerPacket.A_FLAG : ControllerPacket.B_FLAG, 0, 1, + !config.flipFaceButtons ? "A" : "B", R.drawable.facebutton_a,R.drawable.facebutton_a_press, controller, context), + screenScale(BUTTON_BASE_X, height) + rightDisplacement, + screenScale(BUTTON_BASE_Y + 2 * BUTTON_SIZE, height), + screenScale(BUTTON_SIZE, height), + screenScale(BUTTON_SIZE, height) + ); + + controller.addElement(createDigitalButton( + VirtualControllerElement.EID_B, + config.flipFaceButtons ? ControllerPacket.A_FLAG : ControllerPacket.B_FLAG, 0, 1, + config.flipFaceButtons ? "A" : "B", R.drawable.facebutton_b,R.drawable.facebutton_b_press, controller, context), + screenScale(BUTTON_BASE_X + BUTTON_SIZE, height) + rightDisplacement, + screenScale(BUTTON_BASE_Y + BUTTON_SIZE, height), + screenScale(BUTTON_SIZE, height), + screenScale(BUTTON_SIZE, height) + ); + + controller.addElement(createDigitalButton( + VirtualControllerElement.EID_X, + !config.flipFaceButtons ? ControllerPacket.X_FLAG : ControllerPacket.Y_FLAG, 0, 1, + !config.flipFaceButtons ? "X" : "Y", R.drawable.facebutton_x,R.drawable.facebutton_x_press, controller, context), + screenScale(BUTTON_BASE_X - BUTTON_SIZE, height) + rightDisplacement, + screenScale(BUTTON_BASE_Y + BUTTON_SIZE, height), + screenScale(BUTTON_SIZE, height), + screenScale(BUTTON_SIZE, height) + ); + + controller.addElement(createDigitalButton( + VirtualControllerElement.EID_Y, + config.flipFaceButtons ? ControllerPacket.X_FLAG : ControllerPacket.Y_FLAG, 0, 1, + config.flipFaceButtons ? "X" : "Y", R.drawable.facebutton_y,R.drawable.facebutton_y_press, controller, context), + screenScale(BUTTON_BASE_X, height) + rightDisplacement, + screenScale(BUTTON_BASE_Y, height), + screenScale(BUTTON_SIZE, height), + screenScale(BUTTON_SIZE, height) + ); + + controller.addElement(createLeftTrigger( + 1, "LT", R.drawable.facebutton_zl,R.drawable.facebutton_zl_press, controller, context), + screenScale(TRIGGER_L_BASE_X, height), + screenScale(TRIGGER_BASE_Y, height), + screenScale(TRIGGER_WIDTH, height), + screenScale(TRIGGER_HEIGHT, height) + ); + + controller.addElement(createRightTrigger( + 1, "RT", R.drawable.facebutton_zr,R.drawable.facebutton_zr_press, controller, context), + screenScale(TRIGGER_R_BASE_X + TRIGGER_DISTANCE, height) + rightDisplacement, + screenScale(TRIGGER_BASE_Y, height), + screenScale(TRIGGER_WIDTH, height), + screenScale(TRIGGER_HEIGHT, height) + ); + + controller.addElement(createDigitalButton( + VirtualControllerElement.EID_LB, + ControllerPacket.LB_FLAG, 0, 1, "LB", R.drawable.facebutton_l,R.drawable.facebutton_l_press, controller, context), + screenScale(TRIGGER_L_BASE_X + TRIGGER_DISTANCE, height), + screenScale(TRIGGER_BASE_Y, height), + screenScale(TRIGGER_WIDTH, height), + screenScale(TRIGGER_HEIGHT, height) + ); + + controller.addElement(createDigitalButton( + VirtualControllerElement.EID_RB, + ControllerPacket.RB_FLAG, 0, 1, "RB", R.drawable.facebutton_r,R.drawable.facebutton_r_press, controller, context), + screenScale(TRIGGER_R_BASE_X, height) + rightDisplacement, + screenScale(TRIGGER_BASE_Y, height), + screenScale(TRIGGER_WIDTH, height), + screenScale(TRIGGER_HEIGHT, height) + ); + + if(config.enableNewAnalogStick){ + controller.addElement(createLeftStick2(controller, context), + screenScale(ANALOG_L_BASE_X, height), + screenScale(ANALOG_L_BASE_Y, height), + screenScale(ANALOG_SIZE, height), + screenScale(ANALOG_SIZE, height) + ); + + controller.addElement(createRightStick2(controller, context), + screenScale(ANALOG_R_BASE_X, height) + rightDisplacement, + screenScale(ANALOG_R_BASE_Y, height), + screenScale(ANALOG_SIZE, height), + screenScale(ANALOG_SIZE, height) + ); + }else{ + controller.addElement(createLeftStick(controller, context), + screenScale(ANALOG_L_BASE_X, height), + screenScale(ANALOG_L_BASE_Y, height), + screenScale(ANALOG_SIZE, height), + screenScale(ANALOG_SIZE, height) + ); + + controller.addElement(createRightStick(controller, context), + screenScale(ANALOG_R_BASE_X, height) + rightDisplacement, + screenScale(ANALOG_R_BASE_Y, height), + screenScale(ANALOG_SIZE, height), + screenScale(ANALOG_SIZE, height) + ); + } + controller.addElement(createDigitalButton( + VirtualControllerElement.EID_BACK, + ControllerPacket.BACK_FLAG, 0, 2, "BACK", R.drawable.facebutton_minus,R.drawable.facebutton_minus_press, controller, context), + screenScale(BACK_X, height), + screenScale(START_BACK_Y, height), + screenScale(START_BACK_WIDTH, height), + screenScale(START_BACK_HEIGHT, height) + ); + + controller.addElement(createDigitalButton( + VirtualControllerElement.EID_START, + ControllerPacket.PLAY_FLAG, 0, 3, "START", R.drawable.facebutton_plus,R.drawable.facebutton_plus_press, controller, context), + screenScale(START_X, height) + rightDisplacement, + screenScale(START_BACK_Y, height), + screenScale(START_BACK_WIDTH, height), + screenScale(START_BACK_HEIGHT, height) + ); + + controller.addElement(createDigitalButton( + VirtualControllerElement.EID_LSB, + ControllerPacket.LS_CLK_FLAG, 0, 1, "L3", R.drawable.facebutton_l3,R.drawable.facebutton_l3_press, controller, context), + screenScale(TRIGGER_L_BASE_X, height), + screenScale(L3_R3_BASE_Y, height), + screenScale(TRIGGER_WIDTH, height), + screenScale(TRIGGER_HEIGHT, height) + ); + + controller.addElement(createDigitalButton( + VirtualControllerElement.EID_RSB, + ControllerPacket.RS_CLK_FLAG, 0, 1, "R3", R.drawable.facebutton_r3,R.drawable.facebutton_r3_press, controller, context), + screenScale(TRIGGER_R_BASE_X + TRIGGER_DISTANCE, height) + rightDisplacement, + screenScale(L3_R3_BASE_Y, height), + screenScale(TRIGGER_WIDTH, height), + screenScale(TRIGGER_HEIGHT, height) + ); + + controller.addElement(createDigitalButton( + VirtualControllerElement.EID_TOUCHPAD, + ControllerPacket.TOUCHPAD_FLAG, 0, 1, "触控板", R.drawable.facebutton_touchpad_press,R.drawable.facebutton_touchpad, controller, context), + screenScale(50, height), + screenScale(50, height), + screenScale(20, height), + screenScale(12, height) + ); + } + else { + controller.addElement(createDigitalButton( + VirtualControllerElement.EID_LSB, + ControllerPacket.LS_CLK_FLAG, 0, 1, "L3", -1, -1,controller, context), + screenScale(TRIGGER_L_BASE_X, height), + screenScale(L3_R3_BASE_Y, height), + screenScale(TRIGGER_WIDTH, height), + screenScale(TRIGGER_HEIGHT, height) + ); + + controller.addElement(createDigitalButton( + VirtualControllerElement.EID_RSB, + ControllerPacket.RS_CLK_FLAG, 0, 1, "R3", -1,-1, controller, context), + screenScale(TRIGGER_R_BASE_X + TRIGGER_DISTANCE, height) + rightDisplacement, + screenScale(L3_R3_BASE_Y, height), + screenScale(TRIGGER_WIDTH, height), + screenScale(TRIGGER_HEIGHT, height) + ); + } + + controller.setOpacity(config.oscOpacity); + } + + public static void saveProfile(final VirtualController controller, + final Context context) { + SharedPreferences.Editor prefEditor = context.getSharedPreferences(OSC_PREFERENCE, Activity.MODE_PRIVATE).edit(); + + for (VirtualControllerElement element : controller.getElements()) { + String prefKey = ""+element.elementId; + try { + prefEditor.putString(prefKey, element.getConfiguration().toString()); + } catch (JSONException e) { + e.printStackTrace(); + } + } + + prefEditor.apply(); + } + + public static void loadFromPreferences(final VirtualController controller, final Context context) { + SharedPreferences pref = context.getSharedPreferences(OSC_PREFERENCE, Activity.MODE_PRIVATE); + + for (VirtualControllerElement element : controller.getElements()) { + String prefKey = ""+element.elementId; + + String jsonConfig = pref.getString(prefKey, null); + if (jsonConfig != null) { + try { + element.loadConfiguration(new JSONObject(jsonConfig)); + } catch (JSONException e) { + e.printStackTrace(); + + // Remove the corrupt element from the preferences + pref.edit().remove(prefKey).apply(); + } + } + } + } +} diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualControllerElement.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualControllerElement.java old mode 100644 new mode 100755 index cb906dec67..c8e3808ac3 --- a/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualControllerElement.java +++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualControllerElement.java @@ -1,346 +1,360 @@ -/** - * Created by Karim Mreisi. - */ - -package com.limelight.binding.input.virtual_controller; - -import android.app.AlertDialog; -import android.content.Context; -import android.content.DialogInterface; -import android.graphics.Canvas; -import android.graphics.Paint; -import android.util.DisplayMetrics; -import android.view.MotionEvent; -import android.view.View; -import android.widget.FrameLayout; - -import org.json.JSONException; -import org.json.JSONObject; - -public abstract class VirtualControllerElement extends View { - protected static boolean _PRINT_DEBUG_INFORMATION = false; - - public static final int EID_DPAD = 1; - public static final int EID_LT = 2; - public static final int EID_RT = 3; - public static final int EID_LB = 4; - public static final int EID_RB = 5; - public static final int EID_A = 6; - public static final int EID_B = 7; - public static final int EID_X = 8; - public static final int EID_Y = 9; - public static final int EID_BACK = 10; - public static final int EID_START = 11; - public static final int EID_LS = 12; - public static final int EID_RS = 13; - public static final int EID_LSB = 14; - public static final int EID_RSB = 15; - - protected VirtualController virtualController; - protected final int elementId; - - private final Paint paint = new Paint(); - - private int normalColor = 0xF0888888; - protected int pressedColor = 0xF00000FF; - private int configMoveColor = 0xF0FF0000; - private int configResizeColor = 0xF0FF00FF; - private int configSelectedColor = 0xF000FF00; - - protected int startSize_x; - protected int startSize_y; - - float position_pressed_x = 0; - float position_pressed_y = 0; - - private enum Mode { - Normal, - Resize, - Move - } - - private Mode currentMode = Mode.Normal; - - protected VirtualControllerElement(VirtualController controller, Context context, int elementId) { - super(context); - - this.virtualController = controller; - this.elementId = elementId; - } - - protected void moveElement(int pressed_x, int pressed_y, int x, int y) { - int newPos_x = (int) getX() + x - pressed_x; - int newPos_y = (int) getY() + y - pressed_y; - - FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) getLayoutParams(); - - layoutParams.leftMargin = newPos_x > 0 ? newPos_x : 0; - layoutParams.topMargin = newPos_y > 0 ? newPos_y : 0; - layoutParams.rightMargin = 0; - layoutParams.bottomMargin = 0; - - requestLayout(); - } - - protected void resizeElement(int pressed_x, int pressed_y, int width, int height) { - FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) getLayoutParams(); - - int newHeight = height + (startSize_y - pressed_y); - int newWidth = width + (startSize_x - pressed_x); - - layoutParams.height = newHeight > 20 ? newHeight : 20; - layoutParams.width = newWidth > 20 ? newWidth : 20; - - requestLayout(); - } - - @Override - protected void onDraw(Canvas canvas) { - onElementDraw(canvas); - - if (currentMode != Mode.Normal) { - paint.setColor(configSelectedColor); - paint.setStrokeWidth(getDefaultStrokeWidth()); - paint.setStyle(Paint.Style.STROKE); - - canvas.drawRect(paint.getStrokeWidth(), paint.getStrokeWidth(), - getWidth()-paint.getStrokeWidth(), getHeight()-paint.getStrokeWidth(), - paint); - } - - super.onDraw(canvas); - } - - /* - protected void actionShowNormalColorChooser() { - AmbilWarnaDialog colorDialog = new AmbilWarnaDialog(getContext(), normalColor, true, new AmbilWarnaDialog.OnAmbilWarnaListener() { - @Override - public void onCancel(AmbilWarnaDialog dialog) - {} - - @Override - public void onOk(AmbilWarnaDialog dialog, int color) { - normalColor = color; - invalidate(); - } - }); - colorDialog.show(); - } - - protected void actionShowPressedColorChooser() { - AmbilWarnaDialog colorDialog = new AmbilWarnaDialog(getContext(), normalColor, true, new AmbilWarnaDialog.OnAmbilWarnaListener() { - @Override - public void onCancel(AmbilWarnaDialog dialog) { - } - - @Override - public void onOk(AmbilWarnaDialog dialog, int color) { - pressedColor = color; - invalidate(); - } - }); - colorDialog.show(); - } - */ - - protected void actionEnableMove() { - currentMode = Mode.Move; - } - - protected void actionEnableResize() { - currentMode = Mode.Resize; - } - - protected void actionCancel() { - currentMode = Mode.Normal; - invalidate(); - } - - protected int getDefaultColor() { - if (virtualController.getControllerMode() == VirtualController.ControllerMode.MoveButtons) - return configMoveColor; - else if (virtualController.getControllerMode() == VirtualController.ControllerMode.ResizeButtons) - return configResizeColor; - else - return normalColor; - } - - protected int getDefaultStrokeWidth() { - DisplayMetrics screen = getResources().getDisplayMetrics(); - return (int)(screen.heightPixels*0.004f); - } - - protected void showConfigurationDialog() { - AlertDialog.Builder alertBuilder = new AlertDialog.Builder(getContext()); - - alertBuilder.setTitle("Configuration"); - - CharSequence functions[] = new CharSequence[]{ - "Move", - "Resize", - /*election - "Set n - Disable color sormal color", - "Set pressed color", - */ - "Cancel" - }; - - alertBuilder.setItems(functions, new DialogInterface.OnClickListener() { - - @Override - public void onClick(DialogInterface dialog, int which) { - switch (which) { - case 0: { // move - actionEnableMove(); - break; - } - case 1: { // resize - actionEnableResize(); - break; - } - /* - case 2: { // set default color - actionShowNormalColorChooser(); - break; - } - case 3: { // set pressed color - actionShowPressedColorChooser(); - break; - } - */ - default: { // cancel - actionCancel(); - break; - } - } - } - }); - AlertDialog alert = alertBuilder.create(); - // show menu - alert.show(); - } - - @Override - public boolean onTouchEvent(MotionEvent event) { - // Ignore secondary touches on controls - // - // NB: We can get an additional pointer down if the user touches a non-StreamView area - // while also touching an OSC control, even if that pointer down doesn't correspond to - // an area of the OSC control. - if (event.getActionIndex() != 0) { - return true; - } - - if (virtualController.getControllerMode() == VirtualController.ControllerMode.Active) { - return onElementTouchEvent(event); - } - - switch (event.getActionMasked()) { - case MotionEvent.ACTION_DOWN: { - position_pressed_x = event.getX(); - position_pressed_y = event.getY(); - startSize_x = getWidth(); - startSize_y = getHeight(); - - if (virtualController.getControllerMode() == VirtualController.ControllerMode.MoveButtons) - actionEnableMove(); - else if (virtualController.getControllerMode() == VirtualController.ControllerMode.ResizeButtons) - actionEnableResize(); - - return true; - } - case MotionEvent.ACTION_MOVE: { - switch (currentMode) { - case Move: { - moveElement( - (int) position_pressed_x, - (int) position_pressed_y, - (int) event.getX(), - (int) event.getY()); - break; - } - case Resize: { - resizeElement( - (int) position_pressed_x, - (int) position_pressed_y, - (int) event.getX(), - (int) event.getY()); - break; - } - case Normal: { - break; - } - } - return true; - } - case MotionEvent.ACTION_CANCEL: - case MotionEvent.ACTION_UP: { - actionCancel(); - return true; - } - default: { - } - } - return true; - } - - abstract protected void onElementDraw(Canvas canvas); - - abstract public boolean onElementTouchEvent(MotionEvent event); - - protected static final void _DBG(String text) { - if (_PRINT_DEBUG_INFORMATION) { - System.out.println(text); - } - } - - public void setColors(int normalColor, int pressedColor) { - this.normalColor = normalColor; - this.pressedColor = pressedColor; - - invalidate(); - } - - - public void setOpacity(int opacity) { - int hexOpacity = opacity * 255 / 100; - this.normalColor = (hexOpacity << 24) | (normalColor & 0x00FFFFFF); - this.pressedColor = (hexOpacity << 24) | (pressedColor & 0x00FFFFFF); - - invalidate(); - } - - protected final float getPercent(float value, float percent) { - return value / 100 * percent; - } - - protected final int getCorrectWidth() { - return getWidth() > getHeight() ? getHeight() : getWidth(); - } - - - public JSONObject getConfiguration() throws JSONException { - JSONObject configuration = new JSONObject(); - - FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) getLayoutParams(); - - configuration.put("LEFT", layoutParams.leftMargin); - configuration.put("TOP", layoutParams.topMargin); - configuration.put("WIDTH", layoutParams.width); - configuration.put("HEIGHT", layoutParams.height); - - return configuration; - } - - public void loadConfiguration(JSONObject configuration) throws JSONException { - FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) getLayoutParams(); - - layoutParams.leftMargin = configuration.getInt("LEFT"); - layoutParams.topMargin = configuration.getInt("TOP"); - layoutParams.width = configuration.getInt("WIDTH"); - layoutParams.height = configuration.getInt("HEIGHT"); - - requestLayout(); - } -} +/** + * Created by Karim Mreisi. + */ + +package com.limelight.binding.input.virtual_controller; + +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.util.DisplayMetrics; +import android.view.MotionEvent; +import android.view.View; +import android.widget.FrameLayout; + +import com.limelight.Game; + +import org.json.JSONException; +import org.json.JSONObject; + +public abstract class VirtualControllerElement extends View { + protected static boolean _PRINT_DEBUG_INFORMATION = false; + + public static final int EID_DPAD = 1; + public static final int EID_LT = 2; + public static final int EID_RT = 3; + public static final int EID_LB = 4; + public static final int EID_RB = 5; + public static final int EID_A = 6; + public static final int EID_B = 7; + public static final int EID_X = 8; + public static final int EID_Y = 9; + public static final int EID_BACK = 10; + public static final int EID_START = 11; + public static final int EID_LS = 12; + public static final int EID_RS = 13; + public static final int EID_LSB = 14; + public static final int EID_RSB = 15; + //触控板 + public static final int EID_TOUCHPAD = 16; + + protected VirtualController virtualController; + protected final int elementId; + + private final Paint paint = new Paint(); + + protected int normalColor = 0xF0888888; + protected int pressedColor = 0xF07272ED; + private int configMoveColor = 0xF0FF0000; + private int configResizeColor = 0xF0FF00FF; + private int configSelectedColor = 0xF000FF00; + + private int configDisabledColor = 0xF0AAAAAA; + protected int startSize_x; + protected int startSize_y; + + float position_pressed_x = 0; + float position_pressed_y = 0; + + public boolean enabled = true; + + private enum Mode { + Normal, + Resize, + Move + } + + private Mode currentMode = Mode.Normal; + + protected VirtualControllerElement(VirtualController controller, Context context, int elementId) { + super(context); + + this.virtualController = controller; + this.elementId = elementId; + } + + protected void moveElement(int pressed_x, int pressed_y, int x, int y) { + int newPos_x = (int) getX() + x - pressed_x; + int newPos_y = (int) getY() + y - pressed_y; + + FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) getLayoutParams(); + + layoutParams.leftMargin = newPos_x > 0 ? newPos_x : 0; + layoutParams.topMargin = newPos_y > 0 ? newPos_y : 0; + layoutParams.rightMargin = 0; + layoutParams.bottomMargin = 0; + + requestLayout(); + } + + protected void resizeElement(int pressed_x, int pressed_y, int width, int height) { + FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) getLayoutParams(); + + int newHeight = height + (startSize_y - pressed_y); + int newWidth = width + (startSize_x - pressed_x); + + layoutParams.height = newHeight > 20 ? newHeight : 20; + layoutParams.width = newWidth > 20 ? newWidth : 20; + + requestLayout(); + } + + protected void actionDisableEnableButton(){ + enabled = !enabled; + } + + @Override + protected void onDraw(Canvas canvas) { + onElementDraw(canvas); + + if (currentMode != Mode.Normal) { + paint.setColor(configSelectedColor); + paint.setStrokeWidth(getDefaultStrokeWidth()); + paint.setStyle(Paint.Style.STROKE); + + canvas.drawRect(paint.getStrokeWidth(), paint.getStrokeWidth(), + getWidth()-paint.getStrokeWidth(), getHeight()-paint.getStrokeWidth(), + paint); + } + + super.onDraw(canvas); + } + + /* + protected void actionShowNormalColorChooser() { + AmbilWarnaDialog colorDialog = new AmbilWarnaDialog(getContext(), normalColor, true, new AmbilWarnaDialog.OnAmbilWarnaListener() { + @Override + public void onCancel(AmbilWarnaDialog dialog) + {} + + @Override + public void onOk(AmbilWarnaDialog dialog, int color) { + normalColor = color; + invalidate(); + } + }); + colorDialog.show(); + } + + protected void actionShowPressedColorChooser() { + AmbilWarnaDialog colorDialog = new AmbilWarnaDialog(getContext(), normalColor, true, new AmbilWarnaDialog.OnAmbilWarnaListener() { + @Override + public void onCancel(AmbilWarnaDialog dialog) { + } + + @Override + public void onOk(AmbilWarnaDialog dialog, int color) { + pressedColor = color; + invalidate(); + } + }); + colorDialog.show(); + } + */ + + protected void actionEnableMove() { + currentMode = Mode.Move; + } + + protected void actionEnableResize() { + currentMode = Mode.Resize; + } + + protected void actionCancel() { + currentMode = Mode.Normal; + invalidate(); + } + + protected int getDefaultColor() { + if (virtualController.getControllerMode() == VirtualController.ControllerMode.MoveButtons) + return configMoveColor; + else if (virtualController.getControllerMode() == VirtualController.ControllerMode.ResizeButtons) + return configResizeColor; + else if (virtualController.getControllerMode() == VirtualController.ControllerMode.DisableEnableButtons) + return enabled ? configSelectedColor: configDisabledColor; + else return normalColor; + } + + protected int getDefaultStrokeWidth() { + DisplayMetrics screen = getResources().getDisplayMetrics(); + return (int)(screen.heightPixels*0.004f); + } + + protected void showConfigurationDialog() { + AlertDialog.Builder alertBuilder = new AlertDialog.Builder(getContext()); + + alertBuilder.setTitle("Configuration"); + + CharSequence functions[] = new CharSequence[]{ + "Move", + "Resize", + /*election + "Set n + Disable color sormal color", + "Set pressed color", + */ + "Cancel" + }; + + alertBuilder.setItems(functions, new DialogInterface.OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, int which) { + switch (which) { + case 0: { // move + actionEnableMove(); + break; + } + case 1: { // resize + actionEnableResize(); + break; + } + /* + case 2: { // set default color + actionShowNormalColorChooser(); + break; + } + case 3: { // set pressed color + actionShowPressedColorChooser(); + break; + } + */ + default: { // cancel + actionCancel(); + break; + } + } + } + }); + AlertDialog alert = alertBuilder.create(); + // show menu + alert.show(); + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + // Ignore secondary touches on controls + // + // NB: We can get an additional pointer down if the user touches a non-StreamView area + // while also touching an OSC control, even if that pointer down doesn't correspond to + // an area of the OSC control. + if (event.getActionIndex() != 0) { + return true; + } + + if (virtualController.getControllerMode() == VirtualController.ControllerMode.Active) { + return onElementTouchEvent(event); + } + + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: { + position_pressed_x = event.getX(); + position_pressed_y = event.getY(); + startSize_x = getWidth(); + startSize_y = getHeight(); + + if (virtualController.getControllerMode() == VirtualController.ControllerMode.MoveButtons) + actionEnableMove(); + else if (virtualController.getControllerMode() == VirtualController.ControllerMode.ResizeButtons) + actionEnableResize(); + else if (virtualController.getControllerMode() == VirtualController.ControllerMode.DisableEnableButtons) + actionDisableEnableButton(); + return true; + } + case MotionEvent.ACTION_MOVE: { + switch (currentMode) { + case Move: { + moveElement( + (int) position_pressed_x, + (int) position_pressed_y, + (int) event.getX(), + (int) event.getY()); + break; + } + case Resize: { + resizeElement( + (int) position_pressed_x, + (int) position_pressed_y, + (int) event.getX(), + (int) event.getY()); + break; + } + case Normal: { + break; + } + } + return true; + } + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: { + actionCancel(); + return true; + } + default: { + } + } + return true; + } + + abstract protected void onElementDraw(Canvas canvas); + + abstract public boolean onElementTouchEvent(MotionEvent event); + + protected static final void _DBG(String text) { + if (_PRINT_DEBUG_INFORMATION) { +// System.out.println(text); + } + } + + public void setColors(int normalColor, int pressedColor) { + this.normalColor = normalColor; + this.pressedColor = pressedColor; + + invalidate(); + } + + + public void setOpacity(int opacity) { + int hexOpacity = opacity * 255 / 100; + this.normalColor = (hexOpacity << 24) | (normalColor & 0x00FFFFFF); + this.pressedColor = (hexOpacity << 24) | (pressedColor & 0x00FFFFFF); + + invalidate(); + } + + protected final float getPercent(float value, float percent) { + return value / 100 * percent; + } + + protected final int getCorrectWidth() { + return getWidth() > getHeight() ? getHeight() : getWidth(); + } + + + public JSONObject getConfiguration() throws JSONException { + JSONObject configuration = new JSONObject(); + + FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) getLayoutParams(); + + configuration.put("LEFT", layoutParams.leftMargin); + configuration.put("TOP", layoutParams.topMargin); + configuration.put("WIDTH", layoutParams.width); + configuration.put("HEIGHT", layoutParams.height); + configuration.put("ENABLED", enabled); + return configuration; + } + + public void loadConfiguration(JSONObject configuration) throws JSONException { + FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) getLayoutParams(); + + layoutParams.leftMargin = configuration.getInt("LEFT"); + layoutParams.topMargin = configuration.getInt("TOP"); + layoutParams.width = configuration.getInt("WIDTH"); + layoutParams.height = configuration.getInt("HEIGHT"); + enabled = configuration.getBoolean("ENABLED"); + setVisibility(enabled ? VISIBLE: GONE); + requestLayout(); + } +} diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyAnalogStick.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyAnalogStick.java new file mode 100755 index 0000000000..f8ba12947a --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyAnalogStick.java @@ -0,0 +1,351 @@ +/** + * Created by Karim Mreisi. + */ + +package com.limelight.binding.input.virtual_controller.keyboard; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.view.MotionEvent; + +import com.limelight.binding.input.virtual_controller.VirtualController; + +import java.util.ArrayList; +import java.util.List; + +/** + * This is a analog stick on screen element. It is used to get 2-Axis user input. + */ +public class KeyAnalogStick extends keyBoardVirtualControllerElement { + + /** + * outer radius size in percent of the ui element + */ + public static final int SIZE_RADIUS_COMPLETE = 90; + /** + * analog stick size in percent of the ui element + */ + public static final int SIZE_RADIUS_ANALOG_STICK = 90; + /** + * dead zone size in percent of the ui element + */ + public static final int SIZE_RADIUS_DEADZONE = 90; + /** + * time frame for a double click + */ + public final static long timeoutDoubleClick = 350; + + /** + * touch down time until the deadzone is lifted to allow precise movements with the analog sticks + */ + public final static long timeoutDeadzone = 150; + + /** + * Listener interface to update registered observers. + */ + public interface AnalogStickListener { + + /** + * onMovement event will be fired on real analog stick movement (outside of the deadzone). + * + * @param x horizontal position, value from -1.0 ... 0 .. 1.0 + * @param y vertical position, value from -1.0 ... 0 .. 1.0 + */ + void onMovement(float x, float y); + + /** + * onClick event will be fired on click on the analog stick + */ + void onClick(); + + /** + * onDoubleClick event will be fired on a double click in a short time frame on the analog + * stick. + */ + void onDoubleClick(); + + /** + * onRevoke event will be fired on unpress of the analog stick. + */ + void onRevoke(); + } + + /** + * Movement states of the analog sick. + */ + private enum STICK_STATE { + NO_MOVEMENT, + MOVED_IN_DEAD_ZONE, + MOVED_ACTIVE + } + + /** + * Click type states. + */ + private enum CLICK_STATE { + SINGLE, + DOUBLE + } + + /** + * configuration if the analog stick should be displayed as circle or square + */ + private boolean circle_stick = true; // TODO: implement square sick for simulations + + /** + * outer radius, this size will be automatically updated on resize + */ + private float radius_complete = 0; + /** + * analog stick radius, this size will be automatically updated on resize + */ + private float radius_analog_stick = 0; + /** + * dead zone radius, this size will be automatically updated on resize + */ + private float radius_dead_zone = 0; + + /** + * horizontal position in relation to the center of the element + */ + private float relative_x = 0; + /** + * vertical position in relation to the center of the element + */ + private float relative_y = 0; + + + private double movement_radius = 0; + private double movement_angle = 0; + + private float position_stick_x = 0; + private float position_stick_y = 0; + + private final Paint paint = new Paint(); + + private STICK_STATE stick_state = STICK_STATE.NO_MOVEMENT; + private CLICK_STATE click_state = CLICK_STATE.SINGLE; + + private List listeners = new ArrayList<>(); + private long timeLastClick = 0; + + private static double getMovementRadius(float x, float y) { + return Math.sqrt(x * x + y * y); + } + + private static double getAngle(float way_x, float way_y) { + // prevent divisions by zero for corner cases + if (way_x == 0) { + return way_y < 0 ? Math.PI : 0; + } else if (way_y == 0) { + if (way_x > 0) { + return Math.PI * 3 / 2; + } else if (way_x < 0) { + return Math.PI * 1 / 2; + } + } + // return correct calculated angle for each quadrant + if (way_x > 0) { + if (way_y < 0) { + // first quadrant + return 3 * Math.PI / 2 + Math.atan((double) (-way_y / way_x)); + } else { + // second quadrant + return Math.PI + Math.atan((double) (way_x / way_y)); + } + } else { + if (way_y > 0) { + // third quadrant + return Math.PI / 2 + Math.atan((double) (way_y / -way_x)); + } else { + // fourth quadrant + return 0 + Math.atan((double) (-way_x / -way_y)); + } + } + } + + public KeyAnalogStick(KeyBoardController controller, Context context, String elementId) { + super(controller, context, elementId); + // reset stick position + position_stick_x = getWidth() / 2; + position_stick_y = getHeight() / 2; + } + + public void addAnalogStickListener(AnalogStickListener listener) { + listeners.add(listener); + } + + private void notifyOnMovement(float x, float y) { + _DBG("movement x: " + x + " movement y: " + y); + // notify listeners + for (AnalogStickListener listener : listeners) { + listener.onMovement(x, y); + } + } + + private void notifyOnClick() { + _DBG("click"); + // notify listeners + for (AnalogStickListener listener : listeners) { + listener.onClick(); + } + } + + private void notifyOnDoubleClick() { + _DBG("double click"); + // notify listeners + for (AnalogStickListener listener : listeners) { + listener.onDoubleClick(); + } + } + + private void notifyOnRevoke() { + _DBG("revoke"); + // notify listeners + for (AnalogStickListener listener : listeners) { + listener.onRevoke(); + } + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + // calculate new radius sizes depending + radius_complete = getPercent(getCorrectWidth() / 2, 100) - 2 * getDefaultStrokeWidth(); + radius_dead_zone = getPercent(getCorrectWidth() / 2, 30); + radius_analog_stick = getPercent(getCorrectWidth() / 2, 20); + + super.onSizeChanged(w, h, oldw, oldh); + } + + @Override + protected void onElementDraw(Canvas canvas) { + // set transparent background + canvas.drawColor(Color.TRANSPARENT); + + paint.setStyle(Paint.Style.STROKE); + paint.setStrokeWidth(getDefaultStrokeWidth()); + + // draw outer circle + if (!isPressed() || click_state == CLICK_STATE.SINGLE) { + paint.setColor(getDefaultColor()); + } else { + paint.setColor(pressedColor); + } + canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius_complete, paint); + + paint.setColor(getDefaultColor()); + // draw dead zone + canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius_dead_zone, paint); + + // draw stick depending on state + switch (stick_state) { + case NO_MOVEMENT: { + paint.setColor(getDefaultColor()); + canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius_analog_stick, paint); + break; + } + case MOVED_IN_DEAD_ZONE: + case MOVED_ACTIVE: { + paint.setColor(pressedColor); + canvas.drawCircle(position_stick_x, position_stick_y, radius_analog_stick, paint); + break; + } + } + } + + private void updatePosition(long eventTime) { + // get 100% way + float complete = radius_complete - radius_analog_stick; + + // calculate relative way + float correlated_y = (float) (Math.sin(Math.PI / 2 - movement_angle) * (movement_radius)); + float correlated_x = (float) (Math.cos(Math.PI / 2 - movement_angle) * (movement_radius)); + + // update positions + position_stick_x = getWidth() / 2 - correlated_x; + position_stick_y = getHeight() / 2 - correlated_y; + + // Stay active even if we're back in the deadzone because we know the user is actively + // giving analog stick input and we don't want to snap back into the deadzone. + // We also release the deadzone if the user keeps the stick pressed for a bit to allow + // them to make precise movements. + stick_state = (stick_state == STICK_STATE.MOVED_ACTIVE || + eventTime - timeLastClick > timeoutDeadzone || + movement_radius > radius_dead_zone) ? + STICK_STATE.MOVED_ACTIVE : STICK_STATE.MOVED_IN_DEAD_ZONE; + + // trigger move event if state active + if (stick_state == STICK_STATE.MOVED_ACTIVE) { + notifyOnMovement(-correlated_x / complete, correlated_y / complete); + } + } + + @Override + public boolean onElementTouchEvent(MotionEvent event) { + // save last click state + CLICK_STATE lastClickState = click_state; + + // get absolute way for each axis + relative_x = -(getWidth() / 2 - event.getX()); + relative_y = -(getHeight() / 2 - event.getY()); + + // get radius and angel of movement from center + movement_radius = getMovementRadius(relative_x, relative_y); + movement_angle = getAngle(relative_x, relative_y); + + // pass touch event to parent if out of outer circle + if (movement_radius > radius_complete && !isPressed()) + return false; + + // chop radius if out of outer circle or near the edge + if (movement_radius > (radius_complete - radius_analog_stick)) { + movement_radius = radius_complete - radius_analog_stick; + } + + // handle event depending on action + switch (event.getActionMasked()) { + // down event (touch event) + case MotionEvent.ACTION_DOWN: { + // set to dead zoned, will be corrected in update position if necessary + stick_state = STICK_STATE.MOVED_IN_DEAD_ZONE; + // check for double click + if (lastClickState == CLICK_STATE.SINGLE && + event.getEventTime() - timeLastClick <= timeoutDoubleClick) { + click_state = CLICK_STATE.DOUBLE; + notifyOnDoubleClick(); + } else { + click_state = CLICK_STATE.SINGLE; + notifyOnClick(); + } + // reset last click timestamp + timeLastClick = event.getEventTime(); + // set item pressed and update + setPressed(true); + break; + } + // up event (revoke touch) + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: { + setPressed(false); + break; + } + } + + if (isPressed()) { + // when is pressed calculate new positions (will trigger movement if necessary) + updatePosition(event.getEventTime()); + } else { + stick_state = STICK_STATE.NO_MOVEMENT; + notifyOnRevoke(); + + // not longer pressed reset analog stick + notifyOnMovement(0, 0); + } + // refresh view + invalidate(); + // accept the touch event + return true; + } +} diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardAnalogStickButton.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardAnalogStickButton.java new file mode 100755 index 0000000000..6c19173e0a --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardAnalogStickButton.java @@ -0,0 +1,154 @@ +package com.limelight.binding.input.virtual_controller.keyboard; + +import android.content.Context; + + +public class KeyBoardAnalogStickButton extends KeyAnalogStick { + + private final int MIN_CIRCLE_R = 10000; //当摇杆移动的非常小时,不产生操作,摇杆范围-32765 0) { + stickIndex[0] = y; + stickIndex[1] = -1; + } else if (y < 0) { + stickIndex[0] = -1; + stickIndex[1] = -y; + } else { + stickIndex[0] = 0; + stickIndex[1] = -1; + } + + if (x > 0) { + stickIndex[2] = -1; + stickIndex[3] = x; + } else if (x < 0) { + stickIndex[2] = -x; + stickIndex[3] = -1; + } else { + stickIndex[2] = 0; + stickIndex[3] = -1; + } + + + boolean b = x * x + y * y > MIN_CIRCLE_R * MIN_CIRCLE_R; + if (y >= EIGHTH_THREE_PI * x && y >= NEGATIVE_EIGHTH_THREE_PI * x && b){ + //UP + stickBool[0] = true; + stickBool[1] = false; + stickBool[2] = false; + stickBool[3] = false; + } else if (y < EIGHTH_THREE_PI * x && y < NEGATIVE_EIGHTH_THREE_PI * x && b){ + //DOWN + stickBool[0] = false; + stickBool[1] = true; + stickBool[2] = false; + stickBool[3] = false; + } else if (y >= EIGHTH_PI * x && y < NEGATIVE_EIGHTH_PI * x && b){ + //LEFT + stickBool[0] = false; + stickBool[1] = false; + stickBool[2] = true; + stickBool[3] = false; + } else if (y < EIGHTH_PI * x && y >= NEGATIVE_EIGHTH_PI * x && b){ + //RIGHT + stickBool[0] = false; + stickBool[1] = false; + stickBool[2] = false; + stickBool[3] = true; + } else if (y < NEGATIVE_EIGHTH_THREE_PI * x && y >= NEGATIVE_EIGHTH_PI * x && b){ + //UP & LEFT + stickBool[0] = true; + stickBool[1] = false; + stickBool[2] = true; + stickBool[3] = false; + } else if (y >= NEGATIVE_EIGHTH_THREE_PI * x && y < NEGATIVE_EIGHTH_PI * x && b){ + //DOWN & RIGHT + stickBool[0] = false; + stickBool[1] = true; + stickBool[2] = false; + stickBool[3] = true; + } else if (y >= EIGHTH_PI * x && y < EIGHTH_THREE_PI * x && b){ + //UP & RIGHT + stickBool[0] = true; + stickBool[1] = false; + stickBool[2] = false; + stickBool[3] = true; + } else if (y < EIGHTH_PI * x && y >= EIGHTH_THREE_PI * x && b){ + //DOWN & LEFT + stickBool[0] = false; + stickBool[1] = true; + stickBool[2] = true; + stickBool[3] = false; + } else { + stickBool[0] = false; + stickBool[1] = false; + stickBool[2] = false; + stickBool[3] = false; + } + + for (int i = 0; i < 4; i++) { + listener.onkeyEvent(stickSender[i],stickBool[i]); + } + } + + @Override + public void onClick() { + + } + + @Override + public void onDoubleClick() { + listener.onkeyEvent(stickSender[4],true); + } + + @Override + public void onRevoke() { + stickIndex[0] = 0; + stickIndex[1] = -1; + stickIndex[2] = 0; + stickIndex[3] = -1; + stickBool[0] = false; + stickBool[1] = false; + stickBool[2] = false; + stickBool[3] = false; + for (int i = 0; i < 4; i++) { + listener.onkeyEvent(stickSender[i],stickBool[i]); + } + listener.onkeyEvent(stickSender[4],false); + } + }); + } + + public interface KeyBoardAnalogStickListener { + void onkeyEvent(int code,boolean isPress); + } +} diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardAnalogStickButtonFree.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardAnalogStickButtonFree.java new file mode 100755 index 0000000000..2c1e1d20cd --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardAnalogStickButtonFree.java @@ -0,0 +1,154 @@ +package com.limelight.binding.input.virtual_controller.keyboard; + +import android.content.Context; + + +public class KeyBoardAnalogStickButtonFree extends keyAnalogStickFree { + + private final int MIN_CIRCLE_R = 10000; //当摇杆移动的非常小时,不产生操作,摇杆范围-32765 0) { + stickIndex[0] = y; + stickIndex[1] = -1; + } else if (y < 0) { + stickIndex[0] = -1; + stickIndex[1] = -y; + } else { + stickIndex[0] = 0; + stickIndex[1] = -1; + } + + if (x > 0) { + stickIndex[2] = -1; + stickIndex[3] = x; + } else if (x < 0) { + stickIndex[2] = -x; + stickIndex[3] = -1; + } else { + stickIndex[2] = 0; + stickIndex[3] = -1; + } + + + boolean b = x * x + y * y > MIN_CIRCLE_R * MIN_CIRCLE_R; + if (y >= EIGHTH_THREE_PI * x && y >= NEGATIVE_EIGHTH_THREE_PI * x && b){ + //UP + stickBool[0] = true; + stickBool[1] = false; + stickBool[2] = false; + stickBool[3] = false; + } else if (y < EIGHTH_THREE_PI * x && y < NEGATIVE_EIGHTH_THREE_PI * x && b){ + //DOWN + stickBool[0] = false; + stickBool[1] = true; + stickBool[2] = false; + stickBool[3] = false; + } else if (y >= EIGHTH_PI * x && y < NEGATIVE_EIGHTH_PI * x && b){ + //LEFT + stickBool[0] = false; + stickBool[1] = false; + stickBool[2] = true; + stickBool[3] = false; + } else if (y < EIGHTH_PI * x && y >= NEGATIVE_EIGHTH_PI * x && b){ + //RIGHT + stickBool[0] = false; + stickBool[1] = false; + stickBool[2] = false; + stickBool[3] = true; + } else if (y < NEGATIVE_EIGHTH_THREE_PI * x && y >= NEGATIVE_EIGHTH_PI * x && b){ + //UP & LEFT + stickBool[0] = true; + stickBool[1] = false; + stickBool[2] = true; + stickBool[3] = false; + } else if (y >= NEGATIVE_EIGHTH_THREE_PI * x && y < NEGATIVE_EIGHTH_PI * x && b){ + //DOWN & RIGHT + stickBool[0] = false; + stickBool[1] = true; + stickBool[2] = false; + stickBool[3] = true; + } else if (y >= EIGHTH_PI * x && y < EIGHTH_THREE_PI * x && b){ + //UP & RIGHT + stickBool[0] = true; + stickBool[1] = false; + stickBool[2] = false; + stickBool[3] = true; + } else if (y < EIGHTH_PI * x && y >= EIGHTH_THREE_PI * x && b){ + //DOWN & LEFT + stickBool[0] = false; + stickBool[1] = true; + stickBool[2] = true; + stickBool[3] = false; + } else { + stickBool[0] = false; + stickBool[1] = false; + stickBool[2] = false; + stickBool[3] = false; + } + + for (int i = 0; i < 4; i++) { + listener.onkeyEvent(stickSender[i],stickBool[i]); + } + } + + @Override + public void onClick() { + + } + + @Override + public void onDoubleClick() { + listener.onkeyEvent(stickSender[4],true); + } + + @Override + public void onRevoke() { + stickIndex[0] = 0; + stickIndex[1] = -1; + stickIndex[2] = 0; + stickIndex[3] = -1; + stickBool[0] = false; + stickBool[1] = false; + stickBool[2] = false; + stickBool[3] = false; + for (int i = 0; i < 4; i++) { + listener.onkeyEvent(stickSender[i],stickBool[i]); + } + listener.onkeyEvent(stickSender[4],false); + } + }); + } + + public interface KeyBoardAnalogStickListener { + void onkeyEvent(int code,boolean isPress); + } +} diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardController.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardController.java new file mode 100755 index 0000000000..76d8da8dc1 --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardController.java @@ -0,0 +1,219 @@ +/** + * Created by Karim Mreisi. + */ + +package com.limelight.binding.input.virtual_controller.keyboard; + +import android.content.Context; +import android.os.Handler; +import android.os.Looper; +import android.os.Vibrator; +import android.util.DisplayMetrics; +import android.view.KeyEvent; +import android.view.View; +import android.widget.Button; +import android.widget.FrameLayout; +import android.widget.Toast; + +import com.limelight.Game; +import com.limelight.LimeLog; +import com.limelight.R; +import com.limelight.binding.input.ControllerHandler; +import com.limelight.binding.input.virtual_controller.VirtualControllerConfigurationLoader; +import com.limelight.binding.input.virtual_controller.VirtualControllerElement; +import com.limelight.preferences.PreferenceConfiguration; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class KeyBoardController { + + public enum ControllerMode { + Active, + MoveButtons, + ResizeButtons, + DisableEnableButtons + } + + private static final boolean _PRINT_DEBUG_INFORMATION = false; + + private final ControllerHandler controllerHandler; + private final Context context; + private final Handler handler; + + private FrameLayout frame_layout = null; + + ControllerMode currentMode = ControllerMode.Active; + + private Map keyEventRunnableMap = new HashMap<>(); + + private Button buttonConfigure = null; + + private Vibrator vibrator; + private List elements = new ArrayList<>(); + + public KeyBoardController(final ControllerHandler controllerHandler, FrameLayout layout, final Context context) { + this.controllerHandler = controllerHandler; + this.frame_layout = layout; + this.context = context; + this.handler = new Handler(Looper.getMainLooper()); + + this.vibrator = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE); + + buttonConfigure = new Button(context); + buttonConfigure.setAlpha(0.5f); + buttonConfigure.setFocusable(false); + buttonConfigure.setBackgroundResource(R.drawable.ic_keyboard_setting); + buttonConfigure.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + String message; + + if (currentMode == ControllerMode.Active) { + currentMode = ControllerMode.DisableEnableButtons; + showElements(); + message = "配置模式:启用禁用控件!"; + } else if (currentMode == ControllerMode.DisableEnableButtons) { + currentMode = ControllerMode.MoveButtons; + showEnabledElements(); + message = "配置模式:调整控件位置!"; + } else if (currentMode == ControllerMode.MoveButtons) { + currentMode = ControllerMode.ResizeButtons; + message = "配置模式:调整控件大小!"; + } else { + currentMode = ControllerMode.Active; + KeyBoardControllerConfigurationLoader.saveProfile(KeyBoardController.this, context); + message = "退出配置模式!"; + } + + Toast.makeText(context, message, Toast.LENGTH_SHORT).show(); + + buttonConfigure.invalidate(); + + for (keyBoardVirtualControllerElement element : elements) { + element.invalidate(); + } + } + }); + + } + + Handler getHandler() { + return handler; + } + + public void hide() { + for (keyBoardVirtualControllerElement element : elements) { + element.setVisibility(View.GONE); + } + + buttonConfigure.setVisibility(View.GONE); + } + + public void show() { + showEnabledElements(); + + buttonConfigure.setVisibility(View.VISIBLE); + } + + public void showElements() { + for (keyBoardVirtualControllerElement element : elements) { + element.setVisibility(View.VISIBLE); + } + } + + public void showEnabledElements() { + for (keyBoardVirtualControllerElement element : elements) { + element.setVisibility(element.enabled ? View.VISIBLE : View.GONE); + } + } + + public void switchShowHide() { + if (buttonConfigure.getVisibility() == View.VISIBLE) { + hide(); + } else { + show(); + } + } + + public void removeElements() { + for (keyBoardVirtualControllerElement element : elements) { + frame_layout.removeView(element); + } + elements.clear(); + + frame_layout.removeView(buttonConfigure); + } + + public void setOpacity(int opacity) { + for (keyBoardVirtualControllerElement element : elements) { + element.setOpacity(opacity); + } + } + + + public void addElement(keyBoardVirtualControllerElement element, int x, int y, int width, int height) { + elements.add(element); + FrameLayout.LayoutParams layoutParams = new FrameLayout.LayoutParams(width, height); + layoutParams.setMargins(x, y, 0, 0); + + frame_layout.addView(element, layoutParams); + } + + public List getElements() { + return elements; + } + + private static final void _DBG(String text) { + if (_PRINT_DEBUG_INFORMATION) { + LimeLog.info("VirtualController: " + text); + } + } + + public void refreshLayout() { + removeElements(); + + DisplayMetrics screen = context.getResources().getDisplayMetrics(); + + int buttonSize = (int) (screen.heightPixels * 0.06f); + FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(buttonSize, buttonSize); + params.leftMargin = 20 + buttonSize; + params.topMargin = 15; + frame_layout.addView(buttonConfigure, params); + + // Start with the default layout + KeyBoardControllerConfigurationLoader.createDefaultLayout(this, context); + + // Apply user preferences onto the default layout + KeyBoardControllerConfigurationLoader.loadFromPreferences(this, context); + } + + public ControllerMode getControllerMode() { + return currentMode; + } + + public void sendKeyEvent(KeyEvent keyEvent) { + if (Game.instance == null || !Game.instance.connected) { + return; + } + //1-鼠标 0-按键 2-摇杆 3-十字键 + if (keyEvent.getSource() == 1) { + Game.instance.mouseButtonEvent(keyEvent.getKeyCode(), KeyEvent.ACTION_DOWN == keyEvent.getAction()); + } else { + Game.instance.onKey(null, keyEvent.getKeyCode(), keyEvent); + } + if (PreferenceConfiguration.readPreferences(context).enableKeyboardVibrate && vibrator.hasVibrator()) { + vibrator.vibrate(10); + } + } + + public void sendMouseMove(int x,int y){ + if (Game.instance == null || !Game.instance.connected) { + return; + } + Game.instance.mouseMove(x,y); + } + +} diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardControllerConfigurationLoader.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardControllerConfigurationLoader.java new file mode 100755 index 0000000000..8ea16b78df --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardControllerConfigurationLoader.java @@ -0,0 +1,378 @@ +/** + * Created by Karim Mreisi. + */ + +package com.limelight.binding.input.virtual_controller.keyboard; + +import android.app.Activity; +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; +import android.text.TextUtils; +import android.util.DisplayMetrics; +import android.view.KeyEvent; + +import com.limelight.LimeLog; +import com.limelight.preferences.PreferenceConfiguration; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.InputStream; + +public class KeyBoardControllerConfigurationLoader { + public static final String OSC_PREFERENCE = "keyboard_axi_list"; + public static final String OSC_PREFERENCE_VALUE = "OSC_Keyboard"; + + // The default controls are specified using a grid of 128*72 cells at 16:9 + private static int screenScale(int units, int height) { + return (int) (((float) height / (float) 72) * (float) units); + } + + private static int screenScaleSwicth(int result, int height) { + return result * 72 / height; + } + + private static KeyboardDigitalPadButton createDiaitalPadButton(String elementId, int keyCodeLeft, int keyCodeRight, int keyCodeUp, int keyCodeDown, final KeyBoardController controller, final Context context) { + KeyboardDigitalPadButton button = new KeyboardDigitalPadButton(controller, context, elementId); + button.addDigitalPadListener(new KeyboardDigitalPadButton.DigitalPadListener() { + @Override + public void onDirectionChange(int direction) { + if ((direction & KeyboardDigitalPadButton.DIGITAL_PAD_DIRECTION_LEFT) != 0) { + KeyEvent event = new KeyEvent(KeyEvent.ACTION_DOWN, keyCodeLeft); + event.setSource(3); + controller.sendKeyEvent(event); + } else { + KeyEvent event = new KeyEvent(KeyEvent.ACTION_UP, keyCodeLeft); + event.setSource(3); + controller.sendKeyEvent(event); + } + if ((direction & KeyboardDigitalPadButton.DIGITAL_PAD_DIRECTION_RIGHT) != 0) { + KeyEvent event = new KeyEvent(KeyEvent.ACTION_DOWN, keyCodeRight); + event.setSource(3); + controller.sendKeyEvent(event); + } else { + KeyEvent event = new KeyEvent(KeyEvent.ACTION_UP, keyCodeRight); + event.setSource(3); + controller.sendKeyEvent(event); + } + if ((direction & KeyboardDigitalPadButton.DIGITAL_PAD_DIRECTION_UP) != 0) { + KeyEvent event = new KeyEvent(KeyEvent.ACTION_DOWN, keyCodeUp); + event.setSource(3); + controller.sendKeyEvent(event); + } else { + KeyEvent event = new KeyEvent(KeyEvent.ACTION_UP, keyCodeUp); + event.setSource(3); + controller.sendKeyEvent(event); + } + if ((direction & KeyboardDigitalPadButton.DIGITAL_PAD_DIRECTION_DOWN) != 0) { + KeyEvent event = new KeyEvent(KeyEvent.ACTION_DOWN, keyCodeDown); + event.setSource(3); + controller.sendKeyEvent(event); + } else { + KeyEvent event = new KeyEvent(KeyEvent.ACTION_UP, keyCodeDown); + event.setSource(3); + controller.sendKeyEvent(event); + } + } + }); + return button; + } + + + private static KeyBoardAnalogStickButton createKeyBoardAnalogStickButton(final KeyBoardController controller, String elementId, final Context context, int[] keylist) { + + KeyBoardAnalogStickButton analogStick = new KeyBoardAnalogStickButton(controller, elementId, context, keylist); + analogStick.setListener(new KeyBoardAnalogStickButton.KeyBoardAnalogStickListener() { + @Override + public void onkeyEvent(int code, boolean isPress) { + KeyEvent keyEvent = new KeyEvent(isPress ? KeyEvent.ACTION_DOWN : KeyEvent.ACTION_UP, code); + keyEvent.setSource(2); + controller.sendKeyEvent(keyEvent); + } + }); + + return analogStick; + + } + + private static KeyBoardAnalogStickButtonFree createKeyBoardAnalogStickButton2(final KeyBoardController controller, String elementId, final Context context, int[] keylist) { + + KeyBoardAnalogStickButtonFree analogStick = new KeyBoardAnalogStickButtonFree(controller, elementId, context, keylist); + analogStick.setListener(new KeyBoardAnalogStickButtonFree.KeyBoardAnalogStickListener() { + @Override + public void onkeyEvent(int code, boolean isPress) { + KeyEvent keyEvent = new KeyEvent(isPress ? KeyEvent.ACTION_DOWN : KeyEvent.ACTION_UP, code); + keyEvent.setSource(2); + controller.sendKeyEvent(keyEvent); + } + }); + + return analogStick; + + } + + + private static KeyBoardDigitalButton createDigitalButton( + final String elementId, + final int keyShort, + final int type, + final int layer, + final String text, + final int icon, + final KeyBoardController controller, + final Context context) { + KeyBoardDigitalButton button = new KeyBoardDigitalButton(controller, elementId, layer, context); + button.setText(text); + button.setIcon(icon); + + if(elementId.startsWith("m_s_")||elementId.startsWith("key_s_")){ + button.setEnableSwitchDown(true); + } + + button.addDigitalButtonListener(new KeyBoardDigitalButton.DigitalButtonListener() { + @Override + public void onClick() { + KeyEvent keyEvent = new KeyEvent(KeyEvent.ACTION_DOWN, keyShort); + keyEvent.setSource(type); + controller.sendKeyEvent(keyEvent); + } + + @Override + public void onLongClick() { + } + + @Override + public void onRelease() { + KeyEvent keyEvent = new KeyEvent(KeyEvent.ACTION_UP, keyShort); + keyEvent.setSource(type); + controller.sendKeyEvent(keyEvent); + + } + }); + + return button; + } + + + private static KeyBoardTouchPadButton createDigitalTouchButton( + final String elementId, + final int keyShort, + final int type, + final int layer, + final String text, + final int icon, + final KeyBoardController controller, + final Context context) { + KeyBoardTouchPadButton button = new KeyBoardTouchPadButton(controller, elementId, layer, context); + button.setText(text); + button.setIcon(icon); + button.addDigitalButtonListener(new KeyBoardTouchPadButton.DigitalButtonListener() { + @Override + public void onClick() { + int code=keyShort==9?3:1; + KeyEvent keyEvent = new KeyEvent(KeyEvent.ACTION_DOWN, code); + keyEvent.setSource(type); + controller.sendKeyEvent(keyEvent); + } + + @Override + public void onLongClick() { + } + + @Override + public void onMove(int x, int y) { + controller.sendMouseMove(x,y); + } + + @Override + public void onRelease() { + int code=keyShort==9?3:1; + KeyEvent keyEvent = new KeyEvent(KeyEvent.ACTION_UP, code); + keyEvent.setSource(type); + controller.sendKeyEvent(keyEvent); + + } + }); + + return button; + } + + public static void createDefaultLayout(final KeyBoardController controller, final Context context) { + + DisplayMetrics screen = context.getResources().getDisplayMetrics(); + + PreferenceConfiguration config = PreferenceConfiguration.readPreferences(context); + + int height = screen.heightPixels; + + int rightDisplacement = screen.widthPixels - screen.heightPixels * 16 / 9; + + int BUTTON_SIZE = 10; + + int w = screenScale(BUTTON_SIZE, height); + + int maxW = screen.widthPixels / 18; + + if (w > maxW) { + BUTTON_SIZE = screenScaleSwicth(maxW, height); + w = screenScale(BUTTON_SIZE, height); + } + + String result = ""; + try { + InputStream is = context.getAssets().open("config/keyboard.json"); + int lenght = is.available(); + byte[] buffer = new byte[lenght]; + is.read(buffer); + result = new String(buffer, "utf8"); + } catch (Exception e) { + e.printStackTrace(); + } + if (TextUtils.isEmpty(result)) { + return; + } + try { + JSONObject jsonObject = new JSONObject(result); + JSONObject jsonObject1 = jsonObject.getJSONObject("data"); + + JSONArray keystrokeList = jsonObject1.getJSONArray("keystroke"); + JSONArray dpadList = jsonObject1.getJSONArray("dpad"); + JSONArray rockerList = jsonObject1.getJSONArray("rocker"); + JSONArray mouseList = jsonObject1.getJSONArray("mouse"); + + //十字键 + for (int i = 0; i < dpadList.length(); i++) { + JSONObject obj = dpadList.getJSONObject(i); + String code = obj.optString("elementId"); + int keyCodeLeft = obj.optInt("leftCode"); + int keyCodeRight = obj.optInt("rightCode"); + int keyCodeUp = obj.optInt("upCode"); + int keyCodeDown = obj.optInt("downCode"); + controller.addElement(createDiaitalPadButton(code, keyCodeLeft, keyCodeRight, keyCodeUp, keyCodeDown, controller, context), + screenScale(92, height) + rightDisplacement, + screenScale(41, height), + (int) (w * 2.5), (int) (w * 2.5) + ); + } + //摇杆 + for (int i = 0; i < rockerList.length(); i++) { + JSONObject obj = rockerList.getJSONObject(i); + String code = obj.optString("elementId"); + int keyCodeLeft = obj.optInt("leftCode"); + int keyCodeRight = obj.optInt("rightCode"); + int keyCodeUp = obj.optInt("upCode"); + int keyCodeDown = obj.optInt("downCode"); + int keyCodeMiddle = obj.optInt("middleCode"); + int[] keys = new int[]{keyCodeUp, keyCodeDown, keyCodeLeft, keyCodeRight, keyCodeMiddle}; + + if(config.enableNewAnalogStick){ + controller.addElement(createKeyBoardAnalogStickButton2(controller, code, context, keys), + screenScale(4, height), + screenScale(41, height), + (int) (w * 2.5), (int) (w * 2.5) + ); + }else{ + controller.addElement(createKeyBoardAnalogStickButton(controller, code, context, keys), + screenScale(4, height), + screenScale(41, height), + (int) (w * 2.5), (int) (w * 2.5) + ); + } + } + + //鼠标按键 + for (int i = 0; i < mouseList.length(); i++) { + JSONObject obj = mouseList.getJSONObject(i); + obj.put("type", 1); + keystrokeList.put(obj); + } + + double buttonSum = 14.0; + + //普通按键 + for (int i = 0; i < keystrokeList.length(); i++) { + JSONObject obj = keystrokeList.getJSONObject(i); + + String name = obj.optString("name"); + + int type = obj.optInt("type"); + + int code = obj.optInt("code"); + + int switchButton=obj.optInt("switchButton"); + + String elementId = type == 0 ? "key_" + code : "m_" + code; + + if(switchButton==1){ + elementId=type == 0 ? "key_s_" + code : "m_s_" + code; + } + + int lastIndex = (int) (i / buttonSum); + + int x = screenScale(1 + (int) (i % buttonSum) * BUTTON_SIZE, height); + + int y = screenScale(BUTTON_SIZE + lastIndex * BUTTON_SIZE, height); + + if(TextUtils.equals("m_9",elementId)||TextUtils.equals("m_10",elementId)||TextUtils.equals("m_11",elementId)){ + controller.addElement(createDigitalTouchButton(elementId, code, type, 1, name, -1, controller, context), + x, y, + w, w + ); + }else{ + controller.addElement(createDigitalButton(elementId, code, type, 1, name, -1, controller, context), + x, y, + w, w + ); + } + LimeLog.info("x:" + x + ",y:" + y + ",W&H:" + w + "," + screenScale(BUTTON_SIZE, height)); + } + + + } catch (JSONException e) { + throw new RuntimeException(e); + } + + controller.setOpacity(config.oscOpacity); + } + + public static void saveProfile(final KeyBoardController controller, + final Context context) { + String name = PreferenceManager.getDefaultSharedPreferences(context).getString(OSC_PREFERENCE, OSC_PREFERENCE_VALUE); + + SharedPreferences.Editor prefEditor = context.getSharedPreferences(name, Activity.MODE_PRIVATE).edit(); + + for (keyBoardVirtualControllerElement element : controller.getElements()) { + String prefKey = "" + element.elementId; + try { + prefEditor.putString(prefKey, element.getConfiguration().toString()); + } catch (JSONException e) { + e.printStackTrace(); + } + } + prefEditor.apply(); + } + + public static void loadFromPreferences(final KeyBoardController controller, final Context context) { + String name = PreferenceManager.getDefaultSharedPreferences(context).getString(OSC_PREFERENCE, OSC_PREFERENCE_VALUE); + + SharedPreferences pref = context.getSharedPreferences(name, Activity.MODE_PRIVATE); + + for (keyBoardVirtualControllerElement element : controller.getElements()) { + String prefKey = "" + element.elementId; + + String jsonConfig = pref.getString(prefKey, null); + if (jsonConfig != null) { + try { + element.loadConfiguration(new JSONObject(jsonConfig)); + } catch (JSONException e) { + e.printStackTrace(); + + // Remove the corrupt element from the preferences + pref.edit().remove(prefKey).apply(); + } + } + } + } +} diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardDigitalButton.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardDigitalButton.java new file mode 100755 index 0000000000..b90837f7e6 --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardDigitalButton.java @@ -0,0 +1,255 @@ +/** + * Created by Karim Mreisi. + */ + +package com.limelight.binding.input.virtual_controller.keyboard; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.RectF; +import android.graphics.drawable.Drawable; +import android.view.MotionEvent; + +import com.limelight.binding.input.virtual_controller.VirtualController; +import com.limelight.binding.input.virtual_controller.VirtualControllerElement; +import com.limelight.preferences.PreferenceConfiguration; + +import java.util.ArrayList; +import java.util.List; + +/** + * This is a digital button on screen element. It is used to get click and double click user input. + */ +public class KeyBoardDigitalButton extends keyBoardVirtualControllerElement { + + /** + * Listener interface to update registered observers. + */ + public interface DigitalButtonListener { + + /** + * onClick event will be fired on button click. + */ + void onClick(); + + /** + * onLongClick event will be fired on button long click. + */ + void onLongClick(); + + /** + * onRelease event will be fired on button unpress. + */ + void onRelease(); + } + + private List listeners = new ArrayList<>(); + private String text = ""; + private int icon = -1; + private long timerLongClickTimeout = 3000; + private final Runnable longClickRunnable = new Runnable() { + @Override + public void run() { + onLongClickCallback(); + } + }; + + private final Paint paint = new Paint(); + private final RectF rect = new RectF(); + + private int layer; + private KeyBoardDigitalButton movingButton = null; + + boolean inRange(float x, float y) { + return (this.getX() < x && this.getX() + this.getWidth() > x) && + (this.getY() < y && this.getY() + this.getHeight() > y); + } + + public boolean checkMovement(float x, float y, KeyBoardDigitalButton movingButton) { + // check if the movement happened in the same layer + if (movingButton.layer != this.layer) { + return false; + } + + // save current pressed state + boolean wasPressed = isPressed(); + + // check if the movement directly happened on the button + if ((this.movingButton == null || movingButton == this.movingButton) + && this.inRange(x, y)) { + // set button pressed state depending on moving button pressed state + if (this.isPressed() != movingButton.isPressed()) { + this.setPressed(movingButton.isPressed()); + } + } + // check if the movement is outside of the range and the movement button + // is the saved moving button + else if (movingButton == this.movingButton) { + this.setPressed(false); + } + + // check if a change occurred + if (wasPressed != isPressed()) { + if (isPressed()) { + // is pressed set moving button and emit click event + this.movingButton = movingButton; + onClickCallback(); + } else { + // no longer pressed reset moving button and emit release event + this.movingButton = null; + onReleaseCallback(); + } + + invalidate(); + + return true; + } + + return false; + } + + private void checkMovementForAllButtons(float x, float y) { + for (keyBoardVirtualControllerElement element : virtualController.getElements()) { + if (element != this && element instanceof KeyBoardDigitalButton) { + ((KeyBoardDigitalButton) element).checkMovement(x, y, this); + } + } + } + + public KeyBoardDigitalButton(KeyBoardController controller, String elementId, int layer, Context context) { + super(controller, context, elementId); + this.layer = layer; + } + + public void addDigitalButtonListener(DigitalButtonListener listener) { + listeners.add(listener); + } + + public void setText(String text) { + this.text = text; + invalidate(); + } + + public void setIcon(int id) { + this.icon = id; + invalidate(); + } + + @Override + protected void onElementDraw(Canvas canvas) { + // set transparent background + canvas.drawColor(Color.TRANSPARENT); + + paint.setTextSize(getPercent(getWidth(), 25)); + paint.setTextAlign(Paint.Align.CENTER); + paint.setStrokeWidth(getDefaultStrokeWidth()); + + paint.setColor(isPressed() ? pressedColor : getDefaultColor()); + + paint.setStyle(isPressed() ?Paint.Style.FILL_AND_STROKE: Paint.Style.STROKE); + + rect.left = rect.top = paint.getStrokeWidth(); + rect.right = getWidth() - rect.left; + rect.bottom = getHeight() - rect.top; + + if(PreferenceConfiguration.readPreferences(getContext()).enableKeyboardSquare){ + canvas.drawRect(rect,paint); + }else{ + canvas.drawOval(rect, paint); + } + + if (icon != -1) { + Drawable d = getResources().getDrawable(icon); + d.setBounds(5, 5, getWidth() - 5, getHeight() - 5); + d.draw(canvas); + } else { + paint.setStyle(Paint.Style.FILL_AND_STROKE); + paint.setStrokeWidth(getDefaultStrokeWidth()/2); + canvas.drawText(text, getPercent(getWidth(), 50), getPercent(getHeight(), 63), paint); + } + } + + private void onClickCallback() { + _DBG("clicked"); + // notify listeners + for (DigitalButtonListener listener : listeners) { + listener.onClick(); + } + + virtualController.getHandler().removeCallbacks(longClickRunnable); + virtualController.getHandler().postDelayed(longClickRunnable, timerLongClickTimeout); + } + + private void onLongClickCallback() { + _DBG("long click"); + // notify listeners + for (DigitalButtonListener listener : listeners) { + listener.onLongClick(); + } + } + + private void onReleaseCallback() { + _DBG("released"); + // notify listeners + for (DigitalButtonListener listener : listeners) { + listener.onRelease(); + } + + // We may be called for a release without a prior click + virtualController.getHandler().removeCallbacks(longClickRunnable); + } + + private boolean switchDown; + + private boolean enableSwitchDown; + + public void setEnableSwitchDown(boolean enableSwitchDown) { + this.enableSwitchDown = enableSwitchDown; + } + + @Override + public boolean onElementTouchEvent(MotionEvent event) { + // get masked (not specific to a pointer) action + float x = getX() + event.getX(); + float y = getY() + event.getY(); + int action = event.getActionMasked(); + + switch (action) { + case MotionEvent.ACTION_DOWN: { + movingButton = null; + setPressed(true); + onClickCallback(); + + invalidate(); + if(enableSwitchDown){ + switchDown=!switchDown; + } + return true; + } + case MotionEvent.ACTION_MOVE: { + checkMovementForAllButtons(x, y); + + return true; + } + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: { + if(enableSwitchDown&&switchDown){ + return true; + } + setPressed(false); + onReleaseCallback(); + + checkMovementForAllButtons(x, y); + + invalidate(); + + return true; + } + default: { + } + } + return true; + } +} diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardLayoutController.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardLayoutController.java new file mode 100755 index 0000000000..e4eae566cc --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardLayoutController.java @@ -0,0 +1,135 @@ +/** + * Created by Karim Mreisi. + */ + +package com.limelight.binding.input.virtual_controller.keyboard; + +import android.content.Context; +import android.os.Vibrator; +import android.text.TextUtils; +import android.util.DisplayMetrics; +import android.view.Gravity; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.LinearLayout; + +import com.limelight.Game; +import com.limelight.R; +import com.limelight.binding.input.ControllerHandler; +import com.limelight.preferences.PreferenceConfiguration; + +import java.util.ArrayList; +import java.util.List; + +public class KeyBoardLayoutController { + + private final ControllerHandler controllerHandler; + private final Context context; + private FrameLayout frame_layout = null; + private Vibrator vibrator; + private LinearLayout keyboardView; + + public KeyBoardLayoutController(final ControllerHandler controllerHandler, FrameLayout layout, final Context context) { + this.controllerHandler = controllerHandler; + this.frame_layout = layout; + this.context = context; + this.vibrator = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE); + this.keyboardView= (LinearLayout) LayoutInflater.from(context).inflate(R.layout.layout_axixi_keyboard,null); + initKeyboard(); + } + + private void initKeyboard(){ + View.OnTouchListener touchListener = new View.OnTouchListener() { + @Override + public boolean onTouch(View v, MotionEvent event) { + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + // 处理按下事件 + String tag=(String) v.getTag(); + if(TextUtils.equals("hide",tag)){ + return true; + } + KeyEvent keyEvent = new KeyEvent(KeyEvent.ACTION_DOWN, Integer.parseInt(tag)); + keyEvent.setSource(0); + sendKeyEvent(keyEvent); + v.setBackgroundResource(R.drawable.bg_ax_keyboard_button_confirm); + return true; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + // 处理释放事件 + String tag2=(String) v.getTag(); + if(TextUtils.equals("hide",tag2)){ + hide(); + return true; + } + KeyEvent keyUP = new KeyEvent(KeyEvent.ACTION_UP, Integer.parseInt(tag2)); + keyUP.setSource(0); + sendKeyEvent(keyUP); + v.setBackgroundResource(R.drawable.bg_ax_keyboard_button); + return true; + } + return false; + } + }; + for (int i = 0; i < keyboardView.getChildCount(); i++){ + LinearLayout keyboardRow = (LinearLayout) keyboardView.getChildAt(i); + for (int j = 0; j < keyboardRow.getChildCount(); j++){ + keyboardRow.getChildAt(j).setOnTouchListener(touchListener); + } + } + } + + public void hide() { + keyboardView.setVisibility(View.GONE); + } + + public void show() { + keyboardView.setVisibility(View.VISIBLE); + } + + public void switchShowHide() { + if (keyboardView.getVisibility() == View.VISIBLE) { + hide(); + } else { + show(); + } + } + + public void refreshLayout() { + frame_layout.removeView(keyboardView); +// DisplayMetrics screen = context.getResources().getDisplayMetrics(); +// (int)(screen.heightPixels/0.4) + int height=PreferenceConfiguration.readPreferences(context).oscKeyboardHeight; + FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,dip2px(context,height)); + params.gravity= Gravity.BOTTOM; +// params.leftMargin = 20 + buttonSize; +// params.topMargin = 15; + keyboardView.setAlpha(PreferenceConfiguration.readPreferences(context).oscKeyboardOpacity/100f); + frame_layout.addView(keyboardView,params); + + } + + public int dip2px(Context context, float dpValue) { + final float scale = context.getResources().getDisplayMetrics().density; + return (int) (dpValue * scale + 0.5f); + } + + public void sendKeyEvent(KeyEvent keyEvent) { + if (Game.instance == null || !Game.instance.connected) { + return; + } + //1-鼠标 0-按键 2-摇杆 3-十字键 + if (keyEvent.getSource() == 1) { + Game.instance.mouseButtonEvent(keyEvent.getKeyCode(), KeyEvent.ACTION_DOWN == keyEvent.getAction()); + } else { + Game.instance.onKey(null, keyEvent.getKeyCode(), keyEvent); + } +// if (PreferenceConfiguration.readPreferences(context).enableKeyboardVibrate && vibrator.hasVibrator()) { +// vibrator.vibrate(10); +// } + } +} diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardTouchPadButton.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardTouchPadButton.java new file mode 100755 index 0000000000..4604e15d71 --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardTouchPadButton.java @@ -0,0 +1,281 @@ +/** + * Created by Karim Mreisi. + */ + +package com.limelight.binding.input.virtual_controller.keyboard; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.RectF; +import android.graphics.drawable.Drawable; +import android.text.TextUtils; +import android.view.MotionEvent; + +import com.limelight.LimeLog; +import com.limelight.preferences.PreferenceConfiguration; + +import java.util.ArrayList; +import java.util.List; + +/** + * This is a digital button on screen element. It is used to get click and double click user input. + */ +public class KeyBoardTouchPadButton extends keyBoardVirtualControllerElement { + + /** + * Listener interface to update registered observers. + */ + public interface DigitalButtonListener { + + /** + * onClick event will be fired on button click. + */ + void onClick(); + + /** + * onLongClick event will be fired on button long click. + */ + void onLongClick(); + + void onMove(int x, int y); + + /** + * onRelease event will be fired on button unpress. + */ + void onRelease(); + } + + private List listeners = new ArrayList<>(); + private String text = ""; + private int icon = -1; + private long timerLongClickTimeout = 3000; + private final Runnable longClickRunnable = new Runnable() { + @Override + public void run() { + onLongClickCallback(); + } + }; + + private final Paint paint = new Paint(); + private final RectF rect = new RectF(); + + private int layer; + private KeyBoardTouchPadButton movingButton = null; + + boolean inRange(float x, float y) { + return (this.getX() < x && this.getX() + this.getWidth() > x) && + (this.getY() < y && this.getY() + this.getHeight() > y); + } + + public boolean checkMovement(float x, float y, KeyBoardTouchPadButton movingButton) { + // check if the movement happened in the same layer + if (movingButton.layer != this.layer) { + return false; + } + + // save current pressed state + boolean wasPressed = isPressed(); + + // check if the movement directly happened on the button + if ((this.movingButton == null || movingButton == this.movingButton) + && this.inRange(x, y)) { + // set button pressed state depending on moving button pressed state + if (this.isPressed() != movingButton.isPressed()) { + this.setPressed(movingButton.isPressed()); + } + } + // check if the movement is outside of the range and the movement button + // is the saved moving button + else if (movingButton == this.movingButton) { + this.setPressed(false); + } + + // check if a change occurred + if (wasPressed != isPressed()) { + if (isPressed()) { + // is pressed set moving button and emit click event + this.movingButton = movingButton; + onClickCallback(); + } else { + // no longer pressed reset moving button and emit release event + this.movingButton = null; + onReleaseCallback(); + } + + invalidate(); + + return true; + } + + return false; + } + + private void checkMovementForAllButtons(float x, float y) { + for (keyBoardVirtualControllerElement element : virtualController.getElements()) { + if (element != this && element instanceof KeyBoardTouchPadButton) { + ((KeyBoardTouchPadButton) element).checkMovement(x, y, this); + } + } + } + + public KeyBoardTouchPadButton(KeyBoardController controller, String elementId, int layer, Context context) { + super(controller, context, elementId); + this.layer = layer; + preferenceConfiguration=PreferenceConfiguration.readPreferences(context); + } + + public void addDigitalButtonListener(DigitalButtonListener listener) { + listeners.add(listener); + } + + public void setText(String text) { + this.text = text; + invalidate(); + } + + public void setIcon(int id) { + this.icon = id; + invalidate(); + } + + int pressedColor = 0x2BF5F5F9; + + PreferenceConfiguration preferenceConfiguration; + + @Override + protected void onElementDraw(Canvas canvas) { + // set transparent background + canvas.drawColor(Color.TRANSPARENT); + + paint.setTextSize(getPercent(getWidth(), 25)); + paint.setTextAlign(Paint.Align.CENTER); + paint.setStrokeWidth(getDefaultStrokeWidth()); + + paint.setColor(isPressed() ? pressedColor : getDefaultColor()); + + paint.setStyle(isPressed() ? Paint.Style.FILL_AND_STROKE : Paint.Style.STROKE); + + rect.left = rect.top = paint.getStrokeWidth(); + rect.right = getWidth() - rect.left; + rect.bottom = getHeight() - rect.top; + + canvas.drawRect(rect, paint); + + if (icon != -1) { + Drawable d = getResources().getDrawable(icon); + d.setBounds(5, 5, getWidth() - 5, getHeight() - 5); + d.draw(canvas); + } else { + paint.setStyle(Paint.Style.FILL_AND_STROKE); + paint.setStrokeWidth(getDefaultStrokeWidth() / 2); + canvas.drawText(text, getPercent(getWidth(), 50), getPercent(getHeight(), 63), paint); + } + } + + private void onClickCallback() { + _DBG("clicked"); + // notify listeners + for (DigitalButtonListener listener : listeners) { + listener.onClick(); + } + + virtualController.getHandler().removeCallbacks(longClickRunnable); + virtualController.getHandler().postDelayed(longClickRunnable, timerLongClickTimeout); + } + + private void onLongClickCallback() { + _DBG("long click"); + // notify listeners + for (DigitalButtonListener listener : listeners) { + listener.onLongClick(); + } + } + + private void onMoveCallback(int x, int y) { + _DBG("long click"); + // notify listeners + for (DigitalButtonListener listener : listeners) { + listener.onMove(x, y); + } + } + + private void onReleaseCallback() { + _DBG("released"); + // notify listeners + for (DigitalButtonListener listener : listeners) { + listener.onRelease(); + } + + // We may be called for a release without a prior click + virtualController.getHandler().removeCallbacks(longClickRunnable); + } + + private long originalTouchTime = 0; + private int lastTouchX = 0; + private int lastTouchY = 0; + + private double xFactor, yFactor; + + @Override + public boolean onElementTouchEvent(MotionEvent event) { + // get masked (not specific to a pointer) action + int action = event.getActionMasked(); + switch (action) { + case MotionEvent.ACTION_DOWN: { + xFactor = 1280 / (double) getWidth(); + yFactor = 720 / (double) getHeight(); + lastTouchX = (int) event.getX(); + lastTouchY = (int) event.getY(); + movingButton = null; + originalTouchTime = event.getEventTime(); + invalidate(); + return true; + } + case MotionEvent.ACTION_MOVE: { + int deltaX = (int) (event.getX() - lastTouchX); + int deltaY = (int) (event.getY() - lastTouchY); + deltaX = (int) Math.round((double) Math.abs(deltaX) * xFactor); + deltaY = (int) Math.round((double) Math.abs(deltaY) * yFactor); + // Fix up the signs + if (event.getX() < lastTouchX) { + deltaX = -deltaX; + } + if (event.getY() < lastTouchY) { + deltaY = -deltaY; + } + if (event.getEventTime() - originalTouchTime > 100 && !isPressed()) { + setPressed(true); + if(TextUtils.equals(elementId,"m_9")||TextUtils.equals(elementId,"m_11")){ + onClickCallback(); + } + } +// LimeLog.info("touchPadSensitivity"+preferenceConfiguration.touchPadSensitivity); +// LimeLog.info("onElementTouchEvent:" + deltaX + "," + deltaY); + onMoveCallback((int) (deltaX*0.01f*preferenceConfiguration.touchPadSensitivity), (int) (deltaY*0.01f*preferenceConfiguration.touchPadYSensitity)); + if (deltaX != 0) { + lastTouchX = (int) event.getX(); + } + if (deltaY != 0) { + lastTouchY = (int) event.getY(); + } + invalidate(); + return true; + } + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: { + setPressed(false); + if (event.getEventTime() - originalTouchTime <= 200) { + onClickCallback(); + } + onReleaseCallback(); + invalidate(); + return true; + } + default: { + } + } + return true; + } +} diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyboardDigitalPadButton.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyboardDigitalPadButton.java new file mode 100755 index 0000000000..e4fb7fd56b --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyboardDigitalPadButton.java @@ -0,0 +1,202 @@ +package com.limelight.binding.input.virtual_controller.keyboard; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.view.MotionEvent; + +import java.util.ArrayList; +import java.util.List; + +public class KeyboardDigitalPadButton extends keyBoardVirtualControllerElement{ + + private String value; + + public final static int DIGITAL_PAD_DIRECTION_NO_DIRECTION = 0; + int direction = DIGITAL_PAD_DIRECTION_NO_DIRECTION; + public final static int DIGITAL_PAD_DIRECTION_LEFT = 1; + public final static int DIGITAL_PAD_DIRECTION_UP = 2; + public final static int DIGITAL_PAD_DIRECTION_RIGHT = 4; + public final static int DIGITAL_PAD_DIRECTION_DOWN = 8; + List listeners = new ArrayList<>(); + + private static final int DPAD_MARGIN = 5; + + private final Paint paint = new Paint(); + + protected KeyboardDigitalPadButton(KeyBoardController controller, Context context, String elementId) { + super(controller, context, elementId); + } + + public void addDigitalPadListener(DigitalPadListener listener) { + listeners.add(listener); + } + + @Override + protected void onElementDraw(Canvas canvas) { + // set transparent background + canvas.drawColor(Color.TRANSPARENT); + + paint.setTextSize(getPercent(getCorrectWidth(), 20)); + paint.setTextAlign(Paint.Align.CENTER); + paint.setStrokeWidth(getDefaultStrokeWidth()); + + if (direction == DIGITAL_PAD_DIRECTION_NO_DIRECTION) { + // draw no direction rect + paint.setStyle(Paint.Style.STROKE); + paint.setColor(getDefaultColor()); + canvas.drawRect( + getPercent(getWidth(), 36), getPercent(getHeight(), 36), + getPercent(getWidth(), 63), getPercent(getHeight(), 63), + paint + ); + } + + // draw left rect + paint.setColor( + (direction & DIGITAL_PAD_DIRECTION_LEFT) > 0 ? pressedColor : getDefaultColor()); + paint.setStyle(Paint.Style.STROKE); + canvas.drawRect( + paint.getStrokeWidth()+DPAD_MARGIN, getPercent(getHeight(), 33), + getPercent(getWidth(), 33), getPercent(getHeight(), 66), + paint + ); + + + // draw up rect + paint.setColor( + (direction & DIGITAL_PAD_DIRECTION_UP) > 0 ? pressedColor : getDefaultColor()); + paint.setStyle(Paint.Style.STROKE); + canvas.drawRect( + getPercent(getWidth(), 33), paint.getStrokeWidth()+DPAD_MARGIN, + getPercent(getWidth(), 66), getPercent(getHeight(), 33), + paint + ); + + // draw right rect + paint.setColor( + (direction & DIGITAL_PAD_DIRECTION_RIGHT) > 0 ? pressedColor : getDefaultColor()); + paint.setStyle(Paint.Style.STROKE); + canvas.drawRect( + getPercent(getWidth(), 66), getPercent(getHeight(), 33), + getWidth() - (paint.getStrokeWidth()+DPAD_MARGIN), getPercent(getHeight(), 66), + paint + ); + + // draw down rect + paint.setColor( + (direction & DIGITAL_PAD_DIRECTION_DOWN) > 0 ? pressedColor : getDefaultColor()); + paint.setStyle(Paint.Style.STROKE); + canvas.drawRect( + getPercent(getWidth(), 33), getPercent(getHeight(), 66), + getPercent(getWidth(), 66), getHeight() - (paint.getStrokeWidth()+DPAD_MARGIN), + paint + ); + + // draw left up line + paint.setColor(( + (direction & DIGITAL_PAD_DIRECTION_LEFT) > 0 && + (direction & DIGITAL_PAD_DIRECTION_UP) > 0 + ) ? pressedColor : getDefaultColor() + ); + paint.setStyle(Paint.Style.STROKE); + canvas.drawLine( + paint.getStrokeWidth()+DPAD_MARGIN, getPercent(getHeight(), 33), + getPercent(getWidth(), 33), paint.getStrokeWidth()+DPAD_MARGIN, + paint + ); + + // draw up right line + paint.setColor(( + (direction & DIGITAL_PAD_DIRECTION_UP) > 0 && + (direction & DIGITAL_PAD_DIRECTION_RIGHT) > 0 + ) ? pressedColor : getDefaultColor() + ); + paint.setStyle(Paint.Style.STROKE); + canvas.drawLine( + getPercent(getWidth(), 66), paint.getStrokeWidth()+DPAD_MARGIN, + getWidth() - (paint.getStrokeWidth()+DPAD_MARGIN), getPercent(getHeight(), 33), + paint + ); + + // draw right down line + paint.setColor(( + (direction & DIGITAL_PAD_DIRECTION_RIGHT) > 0 && + (direction & DIGITAL_PAD_DIRECTION_DOWN) > 0 + ) ? pressedColor : getDefaultColor() + ); + paint.setStyle(Paint.Style.STROKE); + canvas.drawLine( + getWidth()-paint.getStrokeWidth(), getPercent(getHeight(), 66), + getPercent(getWidth(), 66), getHeight()-(paint.getStrokeWidth()+DPAD_MARGIN), + paint + ); + + // draw down left line + paint.setColor(( + (direction & DIGITAL_PAD_DIRECTION_DOWN) > 0 && + (direction & DIGITAL_PAD_DIRECTION_LEFT) > 0 + ) ? pressedColor : getDefaultColor() + ); + paint.setStyle(Paint.Style.STROKE); + canvas.drawLine( + getPercent(getWidth(), 33), getHeight()-(paint.getStrokeWidth()+DPAD_MARGIN), + paint.getStrokeWidth()+DPAD_MARGIN, getPercent(getHeight(), 66), + paint + ); + } + + private void newDirectionCallback(int direction) { + _DBG("direction: " + direction); + + // notify listeners + for (DigitalPadListener listener : listeners) { + listener.onDirectionChange(direction); + } + } + + @Override + public boolean onElementTouchEvent(MotionEvent event) { + // get masked (not specific to a pointer) action + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: + case MotionEvent.ACTION_MOVE: { + direction = 0; + + if (event.getX() < getPercent(getWidth(), 33)) { + direction |= DIGITAL_PAD_DIRECTION_LEFT; + } + if (event.getX() > getPercent(getWidth(), 66)) { + direction |= DIGITAL_PAD_DIRECTION_RIGHT; + } + if (event.getY() > getPercent(getHeight(), 66)) { + direction |= DIGITAL_PAD_DIRECTION_DOWN; + } + if (event.getY() < getPercent(getHeight(), 33)) { + direction |= DIGITAL_PAD_DIRECTION_UP; + } + newDirectionCallback(direction); + invalidate(); + + return true; + } + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: { + direction = 0; + newDirectionCallback(direction); + invalidate(); + + return true; + } + default: { + } + } + + return true; + } + + public interface DigitalPadListener { + void onDirectionChange(int direction); + } +} diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/keyAnalogStickFree.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/keyAnalogStickFree.java new file mode 100755 index 0000000000..49b981b666 --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/keyAnalogStickFree.java @@ -0,0 +1,419 @@ +/** + * Created by Karim Mreisi. + */ + +package com.limelight.binding.input.virtual_controller.keyboard; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.view.MotionEvent; + +import java.util.ArrayList; +import java.util.List; + +/** + * This is a analog stick on screen element. It is used to get 2-Axis user input. + */ +public class keyAnalogStickFree extends keyBoardVirtualControllerElement { + + /** + * outer radius size in percent of the ui element + */ + public static final int SIZE_RADIUS_COMPLETE = 90; + /** + * analog stick size in percent of the ui element + */ + public static final int SIZE_RADIUS_ANALOG_STICK = 90; + /** + * dead zone size in percent of the ui element + */ + public static final int SIZE_RADIUS_DEADZONE = 90; + /** + * time frame for a double click + */ + public final static long timeoutDoubleClick = 350; + + /** + * touch down time until the deadzone is lifted to allow precise movements with the analog sticks + */ + public final static long timeoutDeadzone = 150; + + /** + * Listener interface to update registered observers. + */ + public interface AnalogStickListener { + + /** + * onMovement event will be fired on real analog stick movement (outside of the deadzone). + * + * @param x horizontal position, value from -1.0 ... 0 .. 1.0 + * @param y vertical position, value from -1.0 ... 0 .. 1.0 + */ + void onMovement(float x, float y); + + /** + * onClick event will be fired on click on the analog stick + */ + void onClick(); + + /** + * onDoubleClick event will be fired on a double click in a short time frame on the analog + * stick. + */ + void onDoubleClick(); + + /** + * onRevoke event will be fired on unpress of the analog stick. + */ + void onRevoke(); + } + + /** + * Movement states of the analog sick. + */ + private enum STICK_STATE { + NO_MOVEMENT, + MOVED_IN_DEAD_ZONE, + MOVED_ACTIVE + } + + /** + * Click type states. + */ + private enum CLICK_STATE { + SINGLE, + DOUBLE + } + + /** + * configuration if the analog stick should be displayed as circle or square + */ + private boolean circle_stick = true; // TODO: implement square sick for simulations + + /** + * outer radius, this size will be automatically updated on resize + */ + private float radius_complete = 0; + /** + * analog stick radius, this size will be automatically updated on resize + */ + private float radius_analog_stick = 0; + /** + * dead zone radius, this size will be automatically updated on resize + */ + private float radius_dead_zone = 0; + + /** + * horizontal position in relation to the center of the element + */ + private float relative_x = 0; + /** + * vertical position in relation to the center of the element + */ + private float relative_y = 0; + + private boolean bIsFingerOnScreen = false; + + private double movement_radius = 0; + private double movement_angle = 0; + + private float position_stick_x = 0; + private float position_stick_y = 0; + + private final Paint paint = new Paint(); + + private STICK_STATE stick_state = STICK_STATE.NO_MOVEMENT; + private CLICK_STATE click_state = CLICK_STATE.SINGLE; + + private List listeners = new ArrayList<>(); + private long timeLastClick = 0; + + private int touchID; + private float touchStartX; + private float touchStartY; + private float touchX; + private float touchY; + + private float touchMaxDistance = 120; + private float touchDeadZone = 20; + private float fDeadzoneSave = 0.01f; + + protected String strStickSide = "L"; + + private static double getMovementRadius(float x, float y) { + return Math.sqrt(x * x + y * y); + } + + private static double getAngle(float way_x, float way_y) { + // prevent divisions by zero for corner cases + if (way_x == 0) { + return way_y < 0 ? Math.PI : 0; + } else if (way_y == 0) { + if (way_x > 0) { + return Math.PI * 3 / 2; + } else if (way_x < 0) { + return Math.PI * 1 / 2; + } + } + // return correct calculated angle for each quadrant + if (way_x > 0) { + if (way_y < 0) { + // first quadrant + return 3 * Math.PI / 2 + Math.atan((double) (-way_y / way_x)); + } else { + // second quadrant + return Math.PI + Math.atan((double) (way_x / way_y)); + } + } else { + if (way_y > 0) { + // third quadrant + return Math.PI / 2 + Math.atan((double) (way_y / -way_x)); + } else { + // fourth quadrant + return 0 + Math.atan((double) (-way_x / -way_y)); + } + } + } + + public keyAnalogStickFree(KeyBoardController controller, Context context, String elementId) { + super(controller, context, elementId); + // reset stick position + position_stick_x = getWidth() / 2; + position_stick_y = getHeight() / 2; + } + + public void addAnalogStickListener(AnalogStickListener listener) { + listeners.add(listener); + } + + private void notifyOnMovement(float x, float y) { + _DBG("movement x: " + x + " movement y: " + y); + // notify listeners + for (AnalogStickListener listener : listeners) { + listener.onMovement(x, y); + } + } + + private void notifyOnClick() { + _DBG("click"); + // notify listeners + for (AnalogStickListener listener : listeners) { + listener.onClick(); + } + } + + private void notifyOnDoubleClick() { + _DBG("double click"); + // notify listeners + for (AnalogStickListener listener : listeners) { + listener.onDoubleClick(); + } + } + + private void notifyOnRevoke() { + _DBG("revoke"); + // notify listeners + for (AnalogStickListener listener : listeners) { + listener.onRevoke(); + } + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + // calculate new radius sizes depending + radius_complete = getPercent(getCorrectWidth() / 2, 100) - 2 * getDefaultStrokeWidth(); + radius_dead_zone = getPercent(getCorrectWidth() / 2, 30); + radius_analog_stick = getPercent(getCorrectWidth() / 2, 20); + + super.onSizeChanged(w, h, oldw, oldh); + } + + + @Override + protected void onElementDraw(Canvas canvas) { + boolean bIsMoving = virtualController.getControllerMode() == KeyBoardController.ControllerMode.MoveButtons; + boolean bIsResizing = virtualController.getControllerMode() == KeyBoardController.ControllerMode.ResizeButtons; + boolean bIsEnable = virtualController.getControllerMode() == KeyBoardController.ControllerMode.DisableEnableButtons; + + if (bIsMoving || bIsResizing || bIsEnable) { + canvas.drawColor(getDefaultColor()); + paint.setColor(Color.WHITE); + int nWidth = getWidth(); + int nHeight = getHeight(); + + paint.setStyle(Paint.Style.FILL); + paint.setTextSize(Math.min(nWidth, nHeight) / 2); + canvas.drawText(strStickSide, nWidth / 2, nHeight / 2, paint); + } + + paint.setStyle(Paint.Style.STROKE); + paint.setStrokeWidth(getDefaultStrokeWidth()); + + if (bIsFingerOnScreen) { + // set transparent background + canvas.drawColor(Color.TRANSPARENT); + //canvas.drawCircle(touchX, touchY, 50, paint); + + // draw outer circle +// if (!isPressed() || click_state == CLICK_STATE.SINGLE) { +// //paint.setColor(getDefaultColor()); +// } else { +// //paint.setColor(pressedColor); +// } +// //canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius_complete, paint); +// +// //paint.setColor(getDefaultColor()); +// // draw dead zone +// //canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius_dead_zone, paint); + // draw stick depending on state + switch (stick_state) { + case NO_MOVEMENT: { + paint.setColor(Color.MAGENTA); + canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius_analog_stick, paint); + break; + } + + case MOVED_IN_DEAD_ZONE: + case MOVED_ACTIVE: { + paint.setColor(pressedColor); + // draw start touch point circle + canvas.drawCircle(touchStartX, touchStartY, + radius_analog_stick / 2.0f, paint); + //paint.setColor(Color.RED); + // line from start point to current touch point +// canvas.drawLine(touchStartX, touchStartY, position_stick_x, position_stick_y, paint); + + //paint.setColor(pressedColor); + canvas.drawCircle(position_stick_x, position_stick_y, radius_analog_stick, paint); + +// float distance = (float) Math.sqrt(Math.pow(touchStartY - position_stick_y, 2) + Math.pow(touchStartX - position_stick_x, 2)); + +// canvas.drawCircle(touchStartX, touchStartY, touchMaxDistance, paint); + break; + } + } + } + } + + + private void updatePosition(long eventTime) { + float complete = radius_complete - radius_analog_stick; + + // calculate relative way + float correlated_y = (float) (Math.sin(Math.PI / 2 - movement_angle) * (movement_radius)); + float correlated_x = (float) (Math.cos(Math.PI / 2 - movement_angle) * (movement_radius)); + + // update positions + position_stick_x = touchStartX - correlated_x; + position_stick_y = touchStartY - correlated_y; + + // Stay active even if we're back in the deadzone because we know the user is actively + // giving analog stick input and we don't want to snap back into the deadzone. + // We also release the deadzone if the user keeps the stick pressed for a bit to allow + // them to make precise movements. + stick_state = (stick_state == keyAnalogStickFree.STICK_STATE.MOVED_ACTIVE || + eventTime - timeLastClick > timeoutDeadzone || + movement_radius > radius_dead_zone) ? + keyAnalogStickFree.STICK_STATE.MOVED_ACTIVE : keyAnalogStickFree.STICK_STATE.MOVED_IN_DEAD_ZONE; + + // trigger move event if state active + if (stick_state == keyAnalogStickFree.STICK_STATE.MOVED_ACTIVE) { + notifyOnMovement(-correlated_x / complete, correlated_y / complete); + } + } + + @Override + public boolean onElementTouchEvent(MotionEvent event) { + // save last click state + CLICK_STATE lastClickState = click_state; + relative_x = -(touchStartX - event.getX()); + relative_y = -(touchStartY - event.getY()); + + // get radius and angel of movement from center + movement_radius = getMovementRadius(relative_x, relative_y); + movement_angle = getAngle(relative_x, relative_y); + + // pass touch event to parent if out of outer circle +// if (movement_radius > radius_complete && !isPressed()) +// return false; + + // chop radius if out of outer circle or near the edge + if (movement_radius > (radius_complete - radius_analog_stick)) { + movement_radius = radius_complete - radius_analog_stick; + } + // handle event depending on action + switch (event.getActionMasked()) { + // down event (touch event) + case MotionEvent.ACTION_DOWN: + case MotionEvent.ACTION_POINTER_DOWN: { + if (!bIsFingerOnScreen) { + touchID = event.getPointerId(event.getActionIndex()); + touchStartX = event.getX(); + touchStartY = event.getY(); + bIsFingerOnScreen = true; + } + + if (touchID == event.getPointerId(event.getActionIndex())) { + touchX = event.getX(); + touchY = event.getY(); + + // set to dead zoned, will be corrected in update position if necessary + stick_state = STICK_STATE.MOVED_IN_DEAD_ZONE; + // check for double click + if (lastClickState == CLICK_STATE.SINGLE && + timeLastClick + timeoutDoubleClick > System.currentTimeMillis()) { + click_state = CLICK_STATE.DOUBLE; + notifyOnDoubleClick(); + } else { + click_state = CLICK_STATE.SINGLE; + notifyOnClick(); + } + // reset last click timestamp + timeLastClick = System.currentTimeMillis(); + // set item pressed and update + setPressed(true); + + updatePosition(event.getEventTime()); + } + break; + } + case MotionEvent.ACTION_MOVE: { + for (int i = 0; i < event.getPointerCount(); i++) { + if (touchID == event.getPointerId(i)) { + touchX = event.getX(); + touchY = event.getY(); + + updatePosition(event.getEventTime()); + } + } + break; + } + // up event (revoke touch) + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_POINTER_UP: { + if (touchID == event.getPointerId(event.getActionIndex())) { + setPressed(false); + bIsFingerOnScreen = false; + } + break; + } + } + + if (isPressed()) { + updatePosition(event.getEventTime()); + // when is pressed calculate new positions (will trigger movement if necessary) + } else { + stick_state = STICK_STATE.NO_MOVEMENT; + notifyOnRevoke(); + + // not longer pressed reset analog stick + notifyOnMovement(0, 0); + } + // refresh view + invalidate(); + // accept the touch event + return true; + } +} diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/keyBoardVirtualControllerElement.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/keyBoardVirtualControllerElement.java new file mode 100755 index 0000000000..0019adf4ec --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/keyBoardVirtualControllerElement.java @@ -0,0 +1,363 @@ +/** + * Created by Karim Mreisi. + */ + +package com.limelight.binding.input.virtual_controller.keyboard; + +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.util.DisplayMetrics; +import android.view.MotionEvent; +import android.view.View; +import android.widget.FrameLayout; + +import com.limelight.Game; +import com.limelight.binding.input.virtual_controller.VirtualController; + +import org.json.JSONException; +import org.json.JSONObject; + +public abstract class keyBoardVirtualControllerElement extends View { + protected static boolean _PRINT_DEBUG_INFORMATION = false; + + public static final int EID_DPAD = 1; + public static final int EID_LT = 2; + public static final int EID_RT = 3; + public static final int EID_LB = 4; + public static final int EID_RB = 5; + public static final int EID_A = 6; + public static final int EID_B = 7; + public static final int EID_X = 8; + public static final int EID_Y = 9; + public static final int EID_BACK = 10; + public static final int EID_START = 11; + public static final int EID_LS = 12; + public static final int EID_RS = 13; + public static final int EID_LSB = 14; + public static final int EID_RSB = 15; + + protected KeyBoardController virtualController; + protected final String elementId; + + private final Paint paint = new Paint(); + + private int normalColor = 0xF0888888; + protected int pressedColor = 0xA3DCDCDE; + private int configMoveColor = 0xF0FF0000; + private int configResizeColor = 0xF0FF00FF; + private int configSelectedColor = 0xF000FF00; + + private int configDisabledColor = 0xF0AAAAAA; + + protected int startSize_x; + protected int startSize_y; + + float position_pressed_x = 0; + float position_pressed_y = 0; + + public boolean enabled = true; + private enum Mode { + Normal, + Resize, + Move + } + + private Mode currentMode = Mode.Normal; + + protected keyBoardVirtualControllerElement(KeyBoardController controller, Context context, String elementId) { + super(context); + + this.virtualController = controller; + this.elementId = elementId; + } + + protected void moveElement(int pressed_x, int pressed_y, int x, int y) { + int newPos_x = (int) getX() + x - pressed_x; + int newPos_y = (int) getY() + y - pressed_y; + + FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) getLayoutParams(); + + layoutParams.leftMargin = newPos_x > 0 ? newPos_x : 0; + layoutParams.topMargin = newPos_y > 0 ? newPos_y : 0; + layoutParams.rightMargin = 0; + layoutParams.bottomMargin = 0; + + requestLayout(); + } + + protected void resizeElement(int pressed_x, int pressed_y, int width, int height) { + FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) getLayoutParams(); + + int newHeight = height + (startSize_y - pressed_y); + int newWidth = width + (startSize_x - pressed_x); + + layoutParams.height = newHeight > 20 ? newHeight : 20; + layoutParams.width = newWidth > 20 ? newWidth : 20; + + requestLayout(); + } + + @Override + protected void onDraw(Canvas canvas) { + onElementDraw(canvas); + + if (currentMode != Mode.Normal) { + paint.setColor(configSelectedColor); + paint.setStrokeWidth(getDefaultStrokeWidth()); + paint.setStyle(Paint.Style.STROKE); + + canvas.drawRect(paint.getStrokeWidth(), paint.getStrokeWidth(), + getWidth()-paint.getStrokeWidth(), getHeight()-paint.getStrokeWidth(), + paint); + } + + super.onDraw(canvas); + } + + /* + protected void actionShowNormalColorChooser() { + AmbilWarnaDialog colorDialog = new AmbilWarnaDialog(getContext(), normalColor, true, new AmbilWarnaDialog.OnAmbilWarnaListener() { + @Override + public void onCancel(AmbilWarnaDialog dialog) + {} + + @Override + public void onOk(AmbilWarnaDialog dialog, int color) { + normalColor = color; + invalidate(); + } + }); + colorDialog.show(); + } + + protected void actionShowPressedColorChooser() { + AmbilWarnaDialog colorDialog = new AmbilWarnaDialog(getContext(), normalColor, true, new AmbilWarnaDialog.OnAmbilWarnaListener() { + @Override + public void onCancel(AmbilWarnaDialog dialog) { + } + + @Override + public void onOk(AmbilWarnaDialog dialog, int color) { + pressedColor = color; + invalidate(); + } + }); + colorDialog.show(); + } + */ + + protected void actionEnableMove() { + currentMode = Mode.Move; + } + + protected void actionEnableResize() { + currentMode = Mode.Resize; + } + + protected void actionCancel() { + currentMode = Mode.Normal; + invalidate(); + } + + protected int getDefaultColor() { + if (virtualController.getControllerMode() == KeyBoardController.ControllerMode.MoveButtons) + return configMoveColor; + else if (virtualController.getControllerMode() == KeyBoardController.ControllerMode.ResizeButtons) + return configResizeColor; + else if (virtualController.getControllerMode() == KeyBoardController.ControllerMode.DisableEnableButtons) + return enabled ? configSelectedColor: configDisabledColor; + else + return normalColor; + } + + protected int getDefaultStrokeWidth() { + DisplayMetrics screen = getResources().getDisplayMetrics(); + return (int)(screen.heightPixels*0.004f); + } + + protected void showConfigurationDialog() { + AlertDialog.Builder alertBuilder = new AlertDialog.Builder(getContext()); + + alertBuilder.setTitle("Configuration"); + + CharSequence functions[] = new CharSequence[]{ + "Move", + "Resize", + /*election + "Set n + Disable color sormal color", + "Set pressed color", + */ + "Cancel" + }; + + alertBuilder.setItems(functions, new DialogInterface.OnClickListener() { + + @Override + public void onClick(DialogInterface dialog, int which) { + switch (which) { + case 0: { // move + actionEnableMove(); + break; + } + case 1: { // resize + actionEnableResize(); + break; + } + /* + case 2: { // set default color + actionShowNormalColorChooser(); + break; + } + case 3: { // set pressed color + actionShowPressedColorChooser(); + break; + } + */ + default: { // cancel + actionCancel(); + break; + } + } + } + }); + AlertDialog alert = alertBuilder.create(); + // show menu + alert.show(); + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + // Ignore secondary touches on controls + // + // NB: We can get an additional pointer down if the user touches a non-StreamView area + // while also touching an OSC control, even if that pointer down doesn't correspond to + // an area of the OSC control. + if (event.getActionIndex() != 0) { + return true; + } + + if (virtualController.getControllerMode() == KeyBoardController.ControllerMode.Active) { + return onElementTouchEvent(event); + } + + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: { + position_pressed_x = event.getX(); + position_pressed_y = event.getY(); + startSize_x = getWidth(); + startSize_y = getHeight(); + + if (virtualController.getControllerMode() == KeyBoardController.ControllerMode.MoveButtons) + actionEnableMove(); + else if (virtualController.getControllerMode() == KeyBoardController.ControllerMode.ResizeButtons) + actionEnableResize(); + else if (virtualController.getControllerMode() == KeyBoardController.ControllerMode.DisableEnableButtons) + actionDisableEnableButton(); + return true; + } + case MotionEvent.ACTION_MOVE: { + switch (currentMode) { + case Move: { + moveElement( + (int) position_pressed_x, + (int) position_pressed_y, + (int) event.getX(), + (int) event.getY()); + break; + } + case Resize: { + resizeElement( + (int) position_pressed_x, + (int) position_pressed_y, + (int) event.getX(), + (int) event.getY()); + break; + } + case Normal: { + break; + } + } + return true; + } + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: { + actionCancel(); + return true; + } + default: { + } + } + return true; + } + + abstract protected void onElementDraw(Canvas canvas); + + abstract public boolean onElementTouchEvent(MotionEvent event); + + protected static final void _DBG(String text) { + if (_PRINT_DEBUG_INFORMATION) { +// System.out.println(text); + } + } + + public void setColors(int normalColor, int pressedColor) { + this.normalColor = normalColor; + this.pressedColor = pressedColor; + + invalidate(); + } + + + public void setOpacity(int opacity) { + int hexOpacity = opacity * 255 / 100; + this.normalColor = (hexOpacity << 24) | (normalColor & 0x00FFFFFF); + this.pressedColor = (hexOpacity << 24) | (pressedColor & 0x00FFFFFF); + + invalidate(); + } + + protected final float getPercent(float value, float percent) { + return value / 100 * percent; + } + + protected final int getCorrectWidth() { + return getWidth() > getHeight() ? getHeight() : getWidth(); + } + + + public JSONObject getConfiguration() throws JSONException { + JSONObject configuration = new JSONObject(); + + FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) getLayoutParams(); + + configuration.put("LEFT", layoutParams.leftMargin); + configuration.put("TOP", layoutParams.topMargin); + configuration.put("WIDTH", layoutParams.width); + configuration.put("HEIGHT", layoutParams.height); + configuration.put("ENABLED", enabled); + return configuration; + } + + public void loadConfiguration(JSONObject configuration) throws JSONException { + FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) getLayoutParams(); + + layoutParams.leftMargin = configuration.getInt("LEFT"); + layoutParams.topMargin = configuration.getInt("TOP"); + layoutParams.width = configuration.getInt("WIDTH"); + layoutParams.height = configuration.getInt("HEIGHT"); + + enabled = configuration.getBoolean("ENABLED"); + + setVisibility(enabled ? VISIBLE: GONE); + requestLayout(); + } + + protected void actionDisableEnableButton(){ + enabled = !enabled; + } + +} diff --git a/app/src/main/java/com/limelight/binding/video/CrashListener.java b/app/src/main/java/com/limelight/binding/video/CrashListener.java old mode 100644 new mode 100755 index 5023da5e39..eba22ff26d --- a/app/src/main/java/com/limelight/binding/video/CrashListener.java +++ b/app/src/main/java/com/limelight/binding/video/CrashListener.java @@ -1,5 +1,5 @@ -package com.limelight.binding.video; - -public interface CrashListener { - void notifyCrash(Exception e); -} +package com.limelight.binding.video; + +public interface CrashListener { + void notifyCrash(Exception e); +} diff --git a/app/src/main/java/com/limelight/binding/video/MediaCodecDecoderRenderer.java b/app/src/main/java/com/limelight/binding/video/MediaCodecDecoderRenderer.java old mode 100644 new mode 100755 index d24ec0dc72..cfce503fa0 --- a/app/src/main/java/com/limelight/binding/video/MediaCodecDecoderRenderer.java +++ b/app/src/main/java/com/limelight/binding/video/MediaCodecDecoderRenderer.java @@ -1,1972 +1,2007 @@ -package com.limelight.binding.video; - -import java.io.IOException; -import java.nio.ByteBuffer; -import java.nio.ByteOrder; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.atomic.AtomicInteger; - -import org.jcodec.codecs.h264.H264Utils; -import org.jcodec.codecs.h264.io.model.SeqParameterSet; -import org.jcodec.codecs.h264.io.model.VUIParameters; - -import com.limelight.BuildConfig; -import com.limelight.LimeLog; -import com.limelight.R; -import com.limelight.nvstream.av.video.VideoDecoderRenderer; -import com.limelight.nvstream.jni.MoonBridge; -import com.limelight.preferences.PreferenceConfiguration; - -import android.annotation.TargetApi; -import android.app.Activity; -import android.content.Context; -import android.media.MediaCodec; -import android.media.MediaCodecInfo; -import android.media.MediaFormat; -import android.media.MediaCodec.BufferInfo; -import android.media.MediaCodec.CodecException; -import android.os.Build; -import android.os.Handler; -import android.os.HandlerThread; -import android.os.Process; -import android.os.SystemClock; -import android.util.Range; -import android.view.Choreographer; -import android.view.SurfaceHolder; - -public class MediaCodecDecoderRenderer extends VideoDecoderRenderer implements Choreographer.FrameCallback { - - private static final boolean USE_FRAME_RENDER_TIME = false; - private static final boolean FRAME_RENDER_TIME_ONLY = USE_FRAME_RENDER_TIME && false; - - // Used on versions < 5.0 - private ByteBuffer[] legacyInputBuffers; - - private MediaCodecInfo avcDecoder; - private MediaCodecInfo hevcDecoder; - private MediaCodecInfo av1Decoder; - - private final ArrayList vpsBuffers = new ArrayList<>(); - private final ArrayList spsBuffers = new ArrayList<>(); - private final ArrayList ppsBuffers = new ArrayList<>(); - private boolean submittedCsd; - private byte[] currentHdrMetadata; - - private int nextInputBufferIndex = -1; - private ByteBuffer nextInputBuffer; - - private Context context; - private Activity activity; - private MediaCodec videoDecoder; - private Thread rendererThread; - private boolean needsSpsBitstreamFixup, isExynos4; - private boolean adaptivePlayback, directSubmit, fusedIdrFrame; - private boolean constrainedHighProfile; - private boolean refFrameInvalidationAvc, refFrameInvalidationHevc, refFrameInvalidationAv1; - private byte optimalSlicesPerFrame; - private boolean refFrameInvalidationActive; - private int initialWidth, initialHeight; - private int videoFormat; - private SurfaceHolder renderTarget; - private volatile boolean stopping; - private CrashListener crashListener; - private boolean reportedCrash; - private int consecutiveCrashCount; - private String glRenderer; - private boolean foreground = true; - private PerfOverlayListener perfListener; - - private static final int CR_MAX_TRIES = 10; - private static final int CR_RECOVERY_TYPE_NONE = 0; - private static final int CR_RECOVERY_TYPE_FLUSH = 1; - private static final int CR_RECOVERY_TYPE_RESTART = 2; - private static final int CR_RECOVERY_TYPE_RESET = 3; - private AtomicInteger codecRecoveryType = new AtomicInteger(CR_RECOVERY_TYPE_NONE); - private final Object codecRecoveryMonitor = new Object(); - - // Each thread that touches the MediaCodec object or any associated buffers must have a flag - // here and must call doCodecRecoveryIfRequired() on a regular basis. - private static final int CR_FLAG_INPUT_THREAD = 0x1; - private static final int CR_FLAG_RENDER_THREAD = 0x2; - private static final int CR_FLAG_CHOREOGRAPHER = 0x4; - private static final int CR_FLAG_ALL = CR_FLAG_INPUT_THREAD | CR_FLAG_RENDER_THREAD | CR_FLAG_CHOREOGRAPHER; - private int codecRecoveryThreadQuiescedFlags = 0; - private int codecRecoveryAttempts = 0; - - private MediaFormat inputFormat; - private MediaFormat outputFormat; - private MediaFormat configuredFormat; - - private boolean needsBaselineSpsHack; - private SeqParameterSet savedSps; - - private RendererException initialException; - private long initialExceptionTimestamp; - private static final int EXCEPTION_REPORT_DELAY_MS = 3000; - - private VideoStats activeWindowVideoStats; - private VideoStats lastWindowVideoStats; - private VideoStats globalVideoStats; - - private long lastTimestampUs; - private int lastFrameNumber; - private int refreshRate; - private PreferenceConfiguration prefs; - - private LinkedBlockingQueue outputBufferQueue = new LinkedBlockingQueue<>(); - private static final int OUTPUT_BUFFER_QUEUE_LIMIT = 2; - private long lastRenderedFrameTimeNanos; - private HandlerThread choreographerHandlerThread; - private Handler choreographerHandler; - - private int numSpsIn; - private int numPpsIn; - private int numVpsIn; - private int numFramesIn; - private int numFramesOut; - - private MediaCodecInfo findAvcDecoder() { - MediaCodecInfo decoder = MediaCodecHelper.findProbableSafeDecoder("video/avc", MediaCodecInfo.CodecProfileLevel.AVCProfileHigh); - if (decoder == null) { - decoder = MediaCodecHelper.findFirstDecoder("video/avc"); - } - return decoder; - } - - @TargetApi(Build.VERSION_CODES.LOLLIPOP) - private boolean decoderCanMeetPerformancePoint(MediaCodecInfo.VideoCapabilities caps, PreferenceConfiguration prefs) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - MediaCodecInfo.VideoCapabilities.PerformancePoint targetPerfPoint = new MediaCodecInfo.VideoCapabilities.PerformancePoint(prefs.width, prefs.height, prefs.fps); - List perfPoints = caps.getSupportedPerformancePoints(); - if (perfPoints != null) { - for (MediaCodecInfo.VideoCapabilities.PerformancePoint perfPoint : perfPoints) { - // If we find a performance point that covers our target, we're good to go - if (perfPoint.covers(targetPerfPoint)) { - return true; - } - } - - // We had performance point data but none met the specified streaming settings - return false; - } - - // Fall-through to try the Android M API if there's no performance point data - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - try { - // We'll ask the decoder what it can do for us at this resolution and see if our - // requested frame rate falls below or inside the range of achievable frame rates. - Range fpsRange = caps.getAchievableFrameRatesFor(prefs.width, prefs.height); - if (fpsRange != null) { - return prefs.fps <= fpsRange.getUpper(); - } - - // Fall-through to try the Android L API if there's no performance point data - } catch (IllegalArgumentException e) { - // Video size not supported at any frame rate - return false; - } - } - - // As a last resort, we will use areSizeAndRateSupported() which is explicitly NOT a - // performance metric, but it can work at least for the purpose of determining if - // the codec is going to die when given a stream with the specified settings. - return caps.areSizeAndRateSupported(prefs.width, prefs.height, prefs.fps); - } - - private boolean decoderCanMeetPerformancePointWithHevcAndNotAvc(MediaCodecInfo hevcDecoderInfo, MediaCodecInfo avcDecoderInfo, PreferenceConfiguration prefs) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - MediaCodecInfo.VideoCapabilities avcCaps = avcDecoderInfo.getCapabilitiesForType("video/avc").getVideoCapabilities(); - MediaCodecInfo.VideoCapabilities hevcCaps = hevcDecoderInfo.getCapabilitiesForType("video/hevc").getVideoCapabilities(); - - return !decoderCanMeetPerformancePoint(avcCaps, prefs) && decoderCanMeetPerformancePoint(hevcCaps, prefs); - } - else { - // No performance data - return false; - } - } - - private boolean decoderCanMeetPerformancePointWithAv1AndNotHevc(MediaCodecInfo av1DecoderInfo, MediaCodecInfo hevcDecoderInfo, PreferenceConfiguration prefs) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - MediaCodecInfo.VideoCapabilities av1Caps = av1DecoderInfo.getCapabilitiesForType("video/av01").getVideoCapabilities(); - MediaCodecInfo.VideoCapabilities hevcCaps = hevcDecoderInfo.getCapabilitiesForType("video/hevc").getVideoCapabilities(); - - return !decoderCanMeetPerformancePoint(hevcCaps, prefs) && decoderCanMeetPerformancePoint(av1Caps, prefs); - } - else { - // No performance data - return false; - } - } - - private boolean decoderCanMeetPerformancePointWithAv1AndNotAvc(MediaCodecInfo av1DecoderInfo, MediaCodecInfo avcDecoderInfo, PreferenceConfiguration prefs) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - MediaCodecInfo.VideoCapabilities avcCaps = avcDecoderInfo.getCapabilitiesForType("video/avc").getVideoCapabilities(); - MediaCodecInfo.VideoCapabilities av1Caps = av1DecoderInfo.getCapabilitiesForType("video/av01").getVideoCapabilities(); - - return !decoderCanMeetPerformancePoint(avcCaps, prefs) && decoderCanMeetPerformancePoint(av1Caps, prefs); - } - else { - // No performance data - return false; - } - } - - private MediaCodecInfo findHevcDecoder(PreferenceConfiguration prefs, boolean meteredNetwork, boolean requestedHdr) { - // Don't return anything if H.264 is forced - if (prefs.videoFormat == PreferenceConfiguration.FormatOption.FORCE_H264) { - return null; - } - - // We don't try the first HEVC decoder. We'd rather fall back to hardware accelerated AVC instead - // - // We need HEVC Main profile, so we could pass that constant to findProbableSafeDecoder, however - // some decoders (at least Qualcomm's Snapdragon 805) don't properly report support - // for even required levels of HEVC. - MediaCodecInfo hevcDecoderInfo = MediaCodecHelper.findProbableSafeDecoder("video/hevc", -1); - if (hevcDecoderInfo != null) { - if (!MediaCodecHelper.decoderIsWhitelistedForHevc(hevcDecoderInfo)) { - LimeLog.info("Found HEVC decoder, but it's not whitelisted - "+hevcDecoderInfo.getName()); - - // Force HEVC enabled if the user asked for it - if (prefs.videoFormat == PreferenceConfiguration.FormatOption.FORCE_HEVC) { - LimeLog.info("Forcing HEVC enabled despite non-whitelisted decoder"); - } - // HDR implies HEVC forced on, since HEVCMain10HDR10 is required for HDR. - else if (requestedHdr) { - LimeLog.info("Forcing HEVC enabled for HDR streaming"); - } - // > 4K streaming also requires HEVC, so force it on there too. - else if (prefs.width > 4096 || prefs.height > 4096) { - LimeLog.info("Forcing HEVC enabled for over 4K streaming"); - } - // Use HEVC if the H.264 decoder is unable to meet the performance point - else if (avcDecoder != null && decoderCanMeetPerformancePointWithHevcAndNotAvc(hevcDecoderInfo, avcDecoder, prefs)) { - LimeLog.info("Using non-whitelisted HEVC decoder to meet performance point"); - } - else { - return null; - } - } - } - - return hevcDecoderInfo; - } - - private MediaCodecInfo findAv1Decoder(PreferenceConfiguration prefs) { - // For now, don't use AV1 unless explicitly requested - if (prefs.videoFormat != PreferenceConfiguration.FormatOption.FORCE_AV1) { - return null; - } - - MediaCodecInfo decoderInfo = MediaCodecHelper.findProbableSafeDecoder("video/av01", -1); - if (decoderInfo != null) { - if (!MediaCodecHelper.isDecoderWhitelistedForAv1(decoderInfo)) { - LimeLog.info("Found AV1 decoder, but it's not whitelisted - "+decoderInfo.getName()); - - // Force HEVC enabled if the user asked for it - if (prefs.videoFormat == PreferenceConfiguration.FormatOption.FORCE_AV1) { - LimeLog.info("Forcing AV1 enabled despite non-whitelisted decoder"); - } - // Use AV1 if the HEVC decoder is unable to meet the performance point - else if (hevcDecoder != null && decoderCanMeetPerformancePointWithAv1AndNotHevc(decoderInfo, hevcDecoder, prefs)) { - LimeLog.info("Using non-whitelisted AV1 decoder to meet performance point"); - } - // Use AV1 if the H.264 decoder is unable to meet the performance point and we have no HEVC decoder - else if (hevcDecoder == null && decoderCanMeetPerformancePointWithAv1AndNotAvc(decoderInfo, avcDecoder, prefs)) { - LimeLog.info("Using non-whitelisted AV1 decoder to meet performance point"); - } - else { - return null; - } - } - } - - return decoderInfo; - } - - public void setRenderTarget(SurfaceHolder renderTarget) { - this.renderTarget = renderTarget; - } - - public MediaCodecDecoderRenderer(Activity activity, PreferenceConfiguration prefs, - CrashListener crashListener, int consecutiveCrashCount, - boolean meteredData, boolean requestedHdr, - String glRenderer, PerfOverlayListener perfListener) { - //dumpDecoders(); - - this.context = activity; - this.activity = activity; - this.prefs = prefs; - this.crashListener = crashListener; - this.consecutiveCrashCount = consecutiveCrashCount; - this.glRenderer = glRenderer; - this.perfListener = perfListener; - - this.activeWindowVideoStats = new VideoStats(); - this.lastWindowVideoStats = new VideoStats(); - this.globalVideoStats = new VideoStats(); - - avcDecoder = findAvcDecoder(); - if (avcDecoder != null) { - LimeLog.info("Selected AVC decoder: "+avcDecoder.getName()); - } - else { - LimeLog.warning("No AVC decoder found"); - } - - hevcDecoder = findHevcDecoder(prefs, meteredData, requestedHdr); - if (hevcDecoder != null) { - LimeLog.info("Selected HEVC decoder: "+hevcDecoder.getName()); - } - else { - LimeLog.info("No HEVC decoder found"); - } - - av1Decoder = findAv1Decoder(prefs); - if (av1Decoder != null) { - LimeLog.info("Selected AV1 decoder: "+av1Decoder.getName()); - } - else { - LimeLog.info("No AV1 decoder found"); - } - - // Set attributes that are queried in getCapabilities(). This must be done here - // because getCapabilities() may be called before setup() in current versions of the common - // library. The limitation of this is that we don't know whether we're using HEVC or AVC. - int avcOptimalSlicesPerFrame = 0; - int hevcOptimalSlicesPerFrame = 0; - if (avcDecoder != null) { - directSubmit = MediaCodecHelper.decoderCanDirectSubmit(avcDecoder.getName()); - refFrameInvalidationAvc = MediaCodecHelper.decoderSupportsRefFrameInvalidationAvc(avcDecoder.getName(), prefs.height); - avcOptimalSlicesPerFrame = MediaCodecHelper.getDecoderOptimalSlicesPerFrame(avcDecoder.getName()); - - if (directSubmit) { - LimeLog.info("Decoder "+avcDecoder.getName()+" will use direct submit"); - } - if (refFrameInvalidationAvc) { - LimeLog.info("Decoder "+avcDecoder.getName()+" will use reference frame invalidation for AVC"); - } - LimeLog.info("Decoder "+avcDecoder.getName()+" wants "+avcOptimalSlicesPerFrame+" slices per frame"); - } - - if (hevcDecoder != null) { - refFrameInvalidationHevc = MediaCodecHelper.decoderSupportsRefFrameInvalidationHevc(hevcDecoder); - hevcOptimalSlicesPerFrame = MediaCodecHelper.getDecoderOptimalSlicesPerFrame(hevcDecoder.getName()); - - if (refFrameInvalidationHevc) { - LimeLog.info("Decoder "+hevcDecoder.getName()+" will use reference frame invalidation for HEVC"); - } - - LimeLog.info("Decoder "+hevcDecoder.getName()+" wants "+hevcOptimalSlicesPerFrame+" slices per frame"); - } - - if (av1Decoder != null) { - refFrameInvalidationAv1 = MediaCodecHelper.decoderSupportsRefFrameInvalidationAv1(av1Decoder); - - if (refFrameInvalidationAv1) { - LimeLog.info("Decoder "+av1Decoder.getName()+" will use reference frame invalidation for AV1"); - } - } - - // Use the larger of the two slices per frame preferences - optimalSlicesPerFrame = (byte)Math.max(avcOptimalSlicesPerFrame, hevcOptimalSlicesPerFrame); - LimeLog.info("Requesting "+optimalSlicesPerFrame+" slices per frame"); - - if (consecutiveCrashCount % 2 == 1) { - refFrameInvalidationAvc = refFrameInvalidationHevc = false; - LimeLog.warning("Disabling RFI due to previous crash"); - } - } - - public boolean isHevcSupported() { - return hevcDecoder != null; - } - - public boolean isAvcSupported() { - return avcDecoder != null; - } - - public boolean isHevcMain10Hdr10Supported() { - if (hevcDecoder == null) { - return false; - } - - for (MediaCodecInfo.CodecProfileLevel profileLevel : hevcDecoder.getCapabilitiesForType("video/hevc").profileLevels) { - if (profileLevel.profile == MediaCodecInfo.CodecProfileLevel.HEVCProfileMain10HDR10) { - LimeLog.info("HEVC decoder "+hevcDecoder.getName()+" supports HEVC Main10 HDR10"); - return true; - } - } - - return false; - } - - public boolean isAv1Supported() { - return av1Decoder != null; - } - - public boolean isAv1Main10Supported() { - if (av1Decoder == null) { - return false; - } - - for (MediaCodecInfo.CodecProfileLevel profileLevel : av1Decoder.getCapabilitiesForType("video/av01").profileLevels) { - if (profileLevel.profile == MediaCodecInfo.CodecProfileLevel.AV1ProfileMain10HDR10) { - LimeLog.info("AV1 decoder "+av1Decoder.getName()+" supports AV1 Main 10 HDR10"); - return true; - } - } - - return false; - } - - public int getPreferredColorSpace() { - // Default to Rec 709 which is probably better supported on modern devices. - // - // We are sticking to Rec 601 on older devices unless the device has an HEVC decoder - // to avoid possible regressions (and they are < 5% of installed devices). If we have - // an HEVC decoder, we will use Rec 709 (even for H.264) since we can't choose a - // colorspace by codec (and it's probably safe to say a SoC with HEVC decoding is - // plenty modern enough to handle H.264 VUI colorspace info). - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O || hevcDecoder != null || av1Decoder != null) { - return MoonBridge.COLORSPACE_REC_709; - } - else { - return MoonBridge.COLORSPACE_REC_601; - } - } - - public int getPreferredColorRange() { - if (prefs.fullRange) { - return MoonBridge.COLOR_RANGE_FULL; - } - else { - return MoonBridge.COLOR_RANGE_LIMITED; - } - } - - public void notifyVideoForeground() { - foreground = true; - } - - public void notifyVideoBackground() { - foreground = false; - } - - public int getActiveVideoFormat() { - return this.videoFormat; - } - - private MediaFormat createBaseMediaFormat(String mimeType) { - MediaFormat videoFormat = MediaFormat.createVideoFormat(mimeType, initialWidth, initialHeight); - - // Avoid setting KEY_FRAME_RATE on Lollipop and earlier to reduce compatibility risk - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - videoFormat.setInteger(MediaFormat.KEY_FRAME_RATE, refreshRate); - } - - // Populate keys for adaptive playback - if (adaptivePlayback) { - videoFormat.setInteger(MediaFormat.KEY_MAX_WIDTH, initialWidth); - videoFormat.setInteger(MediaFormat.KEY_MAX_HEIGHT, initialHeight); - } - - // Android 7.0 adds color options to the MediaFormat - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - videoFormat.setInteger(MediaFormat.KEY_COLOR_RANGE, - getPreferredColorRange() == MoonBridge.COLOR_RANGE_FULL ? - MediaFormat.COLOR_RANGE_FULL : MediaFormat.COLOR_RANGE_LIMITED); - - // If the stream is HDR-capable, the decoder will detect transitions in color standards - // rather than us hardcoding them into the MediaFormat. - if ((getActiveVideoFormat() & MoonBridge.VIDEO_FORMAT_MASK_10BIT) == 0) { - // Set color format keys when not in HDR mode, since we know they won't change - videoFormat.setInteger(MediaFormat.KEY_COLOR_TRANSFER, MediaFormat.COLOR_TRANSFER_SDR_VIDEO); - switch (getPreferredColorSpace()) { - case MoonBridge.COLORSPACE_REC_601: - videoFormat.setInteger(MediaFormat.KEY_COLOR_STANDARD, MediaFormat.COLOR_STANDARD_BT601_NTSC); - break; - case MoonBridge.COLORSPACE_REC_709: - videoFormat.setInteger(MediaFormat.KEY_COLOR_STANDARD, MediaFormat.COLOR_STANDARD_BT709); - break; - case MoonBridge.COLORSPACE_REC_2020: - videoFormat.setInteger(MediaFormat.KEY_COLOR_STANDARD, MediaFormat.COLOR_STANDARD_BT2020); - break; - } - } - } - - return videoFormat; - } - - private void configureAndStartDecoder(MediaFormat format) { - // Set HDR metadata if present - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - if (currentHdrMetadata != null) { - ByteBuffer hdrStaticInfo = ByteBuffer.allocate(25).order(ByteOrder.LITTLE_ENDIAN); - ByteBuffer hdrMetadata = ByteBuffer.wrap(currentHdrMetadata).order(ByteOrder.LITTLE_ENDIAN); - - // Create a HDMI Dynamic Range and Mastering InfoFrame as defined by CTA-861.3 - hdrStaticInfo.put((byte) 0); // Metadata type - hdrStaticInfo.putShort(hdrMetadata.getShort()); // RX - hdrStaticInfo.putShort(hdrMetadata.getShort()); // RY - hdrStaticInfo.putShort(hdrMetadata.getShort()); // GX - hdrStaticInfo.putShort(hdrMetadata.getShort()); // GY - hdrStaticInfo.putShort(hdrMetadata.getShort()); // BX - hdrStaticInfo.putShort(hdrMetadata.getShort()); // BY - hdrStaticInfo.putShort(hdrMetadata.getShort()); // White X - hdrStaticInfo.putShort(hdrMetadata.getShort()); // White Y - hdrStaticInfo.putShort(hdrMetadata.getShort()); // Max mastering luminance - hdrStaticInfo.putShort(hdrMetadata.getShort()); // Min mastering luminance - hdrStaticInfo.putShort(hdrMetadata.getShort()); // Max content luminance - hdrStaticInfo.putShort(hdrMetadata.getShort()); // Max frame average luminance - - hdrStaticInfo.rewind(); - format.setByteBuffer(MediaFormat.KEY_HDR_STATIC_INFO, hdrStaticInfo); - } - else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - format.removeKey(MediaFormat.KEY_HDR_STATIC_INFO); - } - } - - LimeLog.info("Configuring with format: "+format); - - videoDecoder.configure(format, renderTarget.getSurface(), null, 0); - - configuredFormat = format; - - // After reconfiguration, we must resubmit CSD buffers - submittedCsd = false; - vpsBuffers.clear(); - spsBuffers.clear(); - ppsBuffers.clear(); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - // This will contain the actual accepted input format attributes - inputFormat = videoDecoder.getInputFormat(); - LimeLog.info("Input format: "+inputFormat); - } - - videoDecoder.setVideoScalingMode(MediaCodec.VIDEO_SCALING_MODE_SCALE_TO_FIT); - - // Start the decoder - videoDecoder.start(); - - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - legacyInputBuffers = videoDecoder.getInputBuffers(); - } - } - - private boolean tryConfigureDecoder(MediaCodecInfo selectedDecoderInfo, MediaFormat format, boolean throwOnCodecError) { - boolean configured = false; - try { - videoDecoder = MediaCodec.createByCodecName(selectedDecoderInfo.getName()); - configureAndStartDecoder(format); - LimeLog.info("Using codec " + selectedDecoderInfo.getName() + " for hardware decoding " + format.getString(MediaFormat.KEY_MIME)); - configured = true; - } catch (IllegalArgumentException e) { - e.printStackTrace(); - if (throwOnCodecError) { - throw e; - } - } catch (IllegalStateException e) { - e.printStackTrace(); - if (throwOnCodecError) { - throw e; - } - } catch (IOException e) { - e.printStackTrace(); - if (throwOnCodecError) { - throw new RuntimeException(e); - } - } finally { - if (!configured && videoDecoder != null) { - videoDecoder.release(); - videoDecoder = null; - } - } - return configured; - } - - public int initializeDecoder(boolean throwOnCodecError) { - String mimeType; - MediaCodecInfo selectedDecoderInfo; - - if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_H264) != 0) { - mimeType = "video/avc"; - selectedDecoderInfo = avcDecoder; - - if (avcDecoder == null) { - LimeLog.severe("No available AVC decoder!"); - return -1; - } - - if (initialWidth > 4096 || initialHeight > 4096) { - LimeLog.severe("> 4K streaming only supported on HEVC"); - return -1; - } - - // These fixups only apply to H264 decoders - needsSpsBitstreamFixup = MediaCodecHelper.decoderNeedsSpsBitstreamRestrictions(selectedDecoderInfo.getName()); - needsBaselineSpsHack = MediaCodecHelper.decoderNeedsBaselineSpsHack(selectedDecoderInfo.getName()); - constrainedHighProfile = MediaCodecHelper.decoderNeedsConstrainedHighProfile(selectedDecoderInfo.getName()); - isExynos4 = MediaCodecHelper.isExynos4Device(); - if (needsSpsBitstreamFixup) { - LimeLog.info("Decoder "+selectedDecoderInfo.getName()+" needs SPS bitstream restrictions fixup"); - } - if (needsBaselineSpsHack) { - LimeLog.info("Decoder "+selectedDecoderInfo.getName()+" needs baseline SPS hack"); - } - if (constrainedHighProfile) { - LimeLog.info("Decoder "+selectedDecoderInfo.getName()+" needs constrained high profile"); - } - if (isExynos4) { - LimeLog.info("Decoder "+selectedDecoderInfo.getName()+" is on Exynos 4"); - } - - refFrameInvalidationActive = refFrameInvalidationAvc; - } - else if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_H265) != 0) { - mimeType = "video/hevc"; - selectedDecoderInfo = hevcDecoder; - - if (hevcDecoder == null) { - LimeLog.severe("No available HEVC decoder!"); - return -2; - } - - refFrameInvalidationActive = refFrameInvalidationHevc; - } - else if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_AV1) != 0) { - mimeType = "video/av01"; - selectedDecoderInfo = av1Decoder; - - if (av1Decoder == null) { - LimeLog.severe("No available AV1 decoder!"); - return -2; - } - - refFrameInvalidationActive = refFrameInvalidationAv1; - } - else { - // Unknown format - LimeLog.severe("Unknown format"); - return -3; - } - - adaptivePlayback = MediaCodecHelper.decoderSupportsAdaptivePlayback(selectedDecoderInfo, mimeType); - fusedIdrFrame = MediaCodecHelper.decoderSupportsFusedIdrFrame(selectedDecoderInfo, mimeType); - - for (int tryNumber = 0;; tryNumber++) { - LimeLog.info("Decoder configuration try: "+tryNumber); - - MediaFormat mediaFormat = createBaseMediaFormat(mimeType); - - // This will try low latency options until we find one that works (or we give up). - boolean newFormat = MediaCodecHelper.setDecoderLowLatencyOptions(mediaFormat, selectedDecoderInfo, tryNumber); - - // Throw the underlying codec exception on the last attempt if the caller requested it - if (tryConfigureDecoder(selectedDecoderInfo, mediaFormat, !newFormat && throwOnCodecError)) { - // Success! - break; - } - - if (!newFormat) { - // We couldn't even configure a decoder without any low latency options - return -5; - } - } - - if (USE_FRAME_RENDER_TIME && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - videoDecoder.setOnFrameRenderedListener(new MediaCodec.OnFrameRenderedListener() { - @Override - public void onFrameRendered(MediaCodec mediaCodec, long presentationTimeUs, long renderTimeNanos) { - long delta = (renderTimeNanos / 1000000L) - (presentationTimeUs / 1000); - if (delta >= 0 && delta < 1000) { - if (USE_FRAME_RENDER_TIME) { - activeWindowVideoStats.totalTimeMs += delta; - } - } - } - }, null); - } - - return 0; - } - - @Override - public int setup(int format, int width, int height, int redrawRate) { - this.initialWidth = width; - this.initialHeight = height; - this.videoFormat = format; - this.refreshRate = redrawRate; - - return initializeDecoder(false); - } - - // All threads that interact with the MediaCodec instance must call this function regularly! - private boolean doCodecRecoveryIfRequired(int quiescenceFlag) { - // NB: We cannot check 'stopping' here because we could end up bailing in a partially - // quiesced state that will cause the quiesced threads to never wake up. - if (codecRecoveryType.get() == CR_RECOVERY_TYPE_NONE) { - // Common case - return false; - } - - // We need some sort of recovery, so quiesce all threads before starting that - synchronized (codecRecoveryMonitor) { - if (choreographerHandlerThread == null) { - // If we have no choreographer thread, we can just mark that as quiesced right now. - codecRecoveryThreadQuiescedFlags |= CR_FLAG_CHOREOGRAPHER; - } - - codecRecoveryThreadQuiescedFlags |= quiescenceFlag; - - // This is the final thread to quiesce, so let's perform the codec recovery now. - if (codecRecoveryThreadQuiescedFlags == CR_FLAG_ALL) { - // Input and output buffers are invalidated by stop() and reset(). - nextInputBuffer = null; - nextInputBufferIndex = -1; - outputBufferQueue.clear(); - - // If we just need a flush, do so now with all threads quiesced. - if (codecRecoveryType.get() == CR_RECOVERY_TYPE_FLUSH) { - LimeLog.warning("Flushing decoder"); - try { - videoDecoder.flush(); - codecRecoveryType.set(CR_RECOVERY_TYPE_NONE); - } catch (IllegalStateException e) { - e.printStackTrace(); - - // Something went wrong during the restart, let's use a bigger hammer - // and try a reset instead. - codecRecoveryType.set(CR_RECOVERY_TYPE_RESTART); - } - } - - // We don't count flushes as codec recovery attempts - if (codecRecoveryType.get() != CR_RECOVERY_TYPE_NONE) { - codecRecoveryAttempts++; - LimeLog.info("Codec recovery attempt: "+codecRecoveryAttempts); - } - - // For "recoverable" exceptions, we can just stop, reconfigure, and restart. - if (codecRecoveryType.get() == CR_RECOVERY_TYPE_RESTART) { - LimeLog.warning("Trying to restart decoder after CodecException"); - try { - videoDecoder.stop(); - configureAndStartDecoder(configuredFormat); - codecRecoveryType.set(CR_RECOVERY_TYPE_NONE); - } catch (IllegalArgumentException e) { - e.printStackTrace(); - - // Our Surface is probably invalid, so just stop - stopping = true; - codecRecoveryType.set(CR_RECOVERY_TYPE_NONE); - } catch (IllegalStateException e) { - e.printStackTrace(); - - // Something went wrong during the restart, let's use a bigger hammer - // and try a reset instead. - codecRecoveryType.set(CR_RECOVERY_TYPE_RESET); - } - } - - // For "non-recoverable" exceptions on L+, we can call reset() to recover - // without having to recreate the entire decoder again. - if (codecRecoveryType.get() == CR_RECOVERY_TYPE_RESET && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - LimeLog.warning("Trying to reset decoder after CodecException"); - try { - videoDecoder.reset(); - configureAndStartDecoder(configuredFormat); - codecRecoveryType.set(CR_RECOVERY_TYPE_NONE); - } catch (IllegalArgumentException e) { - e.printStackTrace(); - - // Our Surface is probably invalid, so just stop - stopping = true; - codecRecoveryType.set(CR_RECOVERY_TYPE_NONE); - } catch (IllegalStateException e) { - e.printStackTrace(); - - // Something went wrong during the reset, we'll have to resort to - // releasing and recreating the decoder now. - } - } - - // If we _still_ haven't managed to recover, go for the nuclear option and just - // throw away the old decoder and reinitialize a new one from scratch. - if (codecRecoveryType.get() == CR_RECOVERY_TYPE_RESET) { - LimeLog.warning("Trying to recreate decoder after CodecException"); - videoDecoder.release(); - - try { - int err = initializeDecoder(true); - if (err != 0) { - throw new IllegalStateException("Decoder reset failed: " + err); - } - codecRecoveryType.set(CR_RECOVERY_TYPE_NONE); - } catch (IllegalArgumentException e) { - e.printStackTrace(); - - // Our Surface is probably invalid, so just stop - stopping = true; - codecRecoveryType.set(CR_RECOVERY_TYPE_NONE); - } catch (IllegalStateException e) { - // If we failed to recover after all of these attempts, just crash - if (!reportedCrash) { - reportedCrash = true; - crashListener.notifyCrash(e); - } - throw new RendererException(this, e); - } - } - - // Wake all quiesced threads and allow them to begin work again - codecRecoveryThreadQuiescedFlags = 0; - codecRecoveryMonitor.notifyAll(); - } - else { - // If we haven't quiesced all threads yet, wait to be signalled after recovery. - // The final thread to be quiesced will handle the codec recovery. - while (codecRecoveryType.get() != CR_RECOVERY_TYPE_NONE) { - try { - LimeLog.info("Waiting to quiesce decoder threads: "+codecRecoveryThreadQuiescedFlags); - codecRecoveryMonitor.wait(1000); - } catch (InterruptedException e) { - e.printStackTrace(); - - // InterruptedException clears the thread's interrupt status. Since we can't - // handle that here, we will re-interrupt the thread to set the interrupt - // status back to true. - Thread.currentThread().interrupt(); - - break; - } - } - } - } - - return true; - } - - // Returns true if the exception is transient - private boolean handleDecoderException(IllegalStateException e) { - // Eat decoder exceptions if we're in the process of stopping - if (stopping) { - return false; - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && e instanceof CodecException) { - CodecException codecExc = (CodecException) e; - - if (codecExc.isTransient()) { - // We'll let transient exceptions go - LimeLog.warning(codecExc.getDiagnosticInfo()); - return true; - } - - LimeLog.severe(codecExc.getDiagnosticInfo()); - - // We can attempt a recovery or reset at this stage to try to start decoding again - if (codecRecoveryAttempts < CR_MAX_TRIES) { - // If the exception is non-recoverable or we already require a reset, perform a reset. - // If we have no prior unrecoverable failure, we will try a restart instead. - if (codecExc.isRecoverable()) { - if (codecRecoveryType.compareAndSet(CR_RECOVERY_TYPE_NONE, CR_RECOVERY_TYPE_RESTART)) { - LimeLog.info("Decoder requires restart for recoverable CodecException"); - e.printStackTrace(); - } - else if (codecRecoveryType.compareAndSet(CR_RECOVERY_TYPE_FLUSH, CR_RECOVERY_TYPE_RESTART)) { - LimeLog.info("Decoder flush promoted to restart for recoverable CodecException"); - e.printStackTrace(); - } - else if (codecRecoveryType.get() != CR_RECOVERY_TYPE_RESET && codecRecoveryType.get() != CR_RECOVERY_TYPE_RESTART) { - throw new IllegalStateException("Unexpected codec recovery type: " + codecRecoveryType.get()); - } - } - else if (!codecExc.isRecoverable()) { - if (codecRecoveryType.compareAndSet(CR_RECOVERY_TYPE_NONE, CR_RECOVERY_TYPE_RESET)) { - LimeLog.info("Decoder requires reset for non-recoverable CodecException"); - e.printStackTrace(); - } - else if (codecRecoveryType.compareAndSet(CR_RECOVERY_TYPE_FLUSH, CR_RECOVERY_TYPE_RESET)) { - LimeLog.info("Decoder flush promoted to reset for non-recoverable CodecException"); - e.printStackTrace(); - } - else if (codecRecoveryType.compareAndSet(CR_RECOVERY_TYPE_RESTART, CR_RECOVERY_TYPE_RESET)) { - LimeLog.info("Decoder restart promoted to reset for non-recoverable CodecException"); - e.printStackTrace(); - } - else if (codecRecoveryType.get() != CR_RECOVERY_TYPE_RESET) { - throw new IllegalStateException("Unexpected codec recovery type: " + codecRecoveryType.get()); - } - } - - // The recovery will take place when all threads reach doCodecRecoveryIfRequired(). - return false; - } - } - else { - // IllegalStateException was primarily used prior to the introduction of CodecException. - // Recovery from this requires a full decoder reset. - // - // NB: CodecException is an IllegalStateException, so we must check for it first. - if (codecRecoveryAttempts < CR_MAX_TRIES) { - if (codecRecoveryType.compareAndSet(CR_RECOVERY_TYPE_NONE, CR_RECOVERY_TYPE_RESET)) { - LimeLog.info("Decoder requires reset for IllegalStateException"); - e.printStackTrace(); - } - else if (codecRecoveryType.compareAndSet(CR_RECOVERY_TYPE_FLUSH, CR_RECOVERY_TYPE_RESET)) { - LimeLog.info("Decoder flush promoted to reset for IllegalStateException"); - e.printStackTrace(); - } - else if (codecRecoveryType.compareAndSet(CR_RECOVERY_TYPE_RESTART, CR_RECOVERY_TYPE_RESET)) { - LimeLog.info("Decoder restart promoted to reset for IllegalStateException"); - e.printStackTrace(); - } - else if (codecRecoveryType.get() != CR_RECOVERY_TYPE_RESET) { - throw new IllegalStateException("Unexpected codec recovery type: " + codecRecoveryType.get()); - } - - return false; - } - } - - // Only throw if we're not in the middle of codec recovery - if (codecRecoveryType.get() == CR_RECOVERY_TYPE_NONE) { - // - // There seems to be a race condition with decoder/surface teardown causing some - // decoders to to throw IllegalStateExceptions even before 'stopping' is set. - // To workaround this while allowing real exceptions to propagate, we will eat the - // first exception. If we are still receiving exceptions 3 seconds later, we will - // throw the original exception again. - // - if (initialException != null) { - // This isn't the first time we've had an exception processing video - if (SystemClock.uptimeMillis() - initialExceptionTimestamp >= EXCEPTION_REPORT_DELAY_MS) { - // It's been over 3 seconds and we're still getting exceptions. Throw the original now. - if (!reportedCrash) { - reportedCrash = true; - crashListener.notifyCrash(initialException); - } - throw initialException; - } - } - else { - // This is the first exception we've hit - initialException = new RendererException(this, e); - initialExceptionTimestamp = SystemClock.uptimeMillis(); - } - } - - // Not transient - return false; - } - - @Override - public void doFrame(long frameTimeNanos) { - // Do nothing if we're stopping - if (stopping) { - return; - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - frameTimeNanos -= activity.getWindowManager().getDefaultDisplay().getAppVsyncOffsetNanos(); - } - - // Don't render unless a new frame is due. This prevents microstutter when streaming - // at a frame rate that doesn't match the display (such as 60 FPS on 120 Hz). - long actualFrameTimeDeltaNs = frameTimeNanos - lastRenderedFrameTimeNanos; - long expectedFrameTimeDeltaNs = 800000000 / refreshRate; // within 80% of the next frame - if (actualFrameTimeDeltaNs >= expectedFrameTimeDeltaNs) { - // Render up to one frame when in frame pacing mode. - // - // NB: Since the queue limit is 2, we won't starve the decoder of output buffers - // by holding onto them for too long. This also ensures we will have that 1 extra - // frame of buffer to smooth over network/rendering jitter. - Integer nextOutputBuffer = outputBufferQueue.poll(); - if (nextOutputBuffer != null) { - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - videoDecoder.releaseOutputBuffer(nextOutputBuffer, frameTimeNanos); - } - else { - videoDecoder.releaseOutputBuffer(nextOutputBuffer, true); - } - - lastRenderedFrameTimeNanos = frameTimeNanos; - activeWindowVideoStats.totalFramesRendered++; - } catch (IllegalStateException ignored) { - try { - // Try to avoid leaking the output buffer by releasing it without rendering - videoDecoder.releaseOutputBuffer(nextOutputBuffer, false); - } catch (IllegalStateException e) { - // This will leak nextOutputBuffer, but there's really nothing else we can do - e.printStackTrace(); - handleDecoderException(e); - } - } - } - } - - // Attempt codec recovery even if we have nothing to render right now. Recovery can still - // be required even if the codec died before giving any output. - doCodecRecoveryIfRequired(CR_FLAG_CHOREOGRAPHER); - - // Request another callback for next frame - Choreographer.getInstance().postFrameCallback(this); - } - - private void startChoreographerThread() { - if (prefs.framePacing != PreferenceConfiguration.FRAME_PACING_BALANCED) { - // Not using Choreographer in this pacing mode - return; - } - - // We use a separate thread to avoid any main thread delays from delaying rendering - choreographerHandlerThread = new HandlerThread("Video - Choreographer", Process.THREAD_PRIORITY_DEFAULT + Process.THREAD_PRIORITY_MORE_FAVORABLE); - choreographerHandlerThread.start(); - - // Start the frame callbacks - choreographerHandler = new Handler(choreographerHandlerThread.getLooper()); - choreographerHandler.post(new Runnable() { - @Override - public void run() { - Choreographer.getInstance().postFrameCallback(MediaCodecDecoderRenderer.this); - } - }); - } - - private void startRendererThread() - { - rendererThread = new Thread() { - @Override - public void run() { - BufferInfo info = new BufferInfo(); - while (!stopping) { - try { - // Try to output a frame - int outIndex = videoDecoder.dequeueOutputBuffer(info, 50000); - if (outIndex >= 0) { - long presentationTimeUs = info.presentationTimeUs; - int lastIndex = outIndex; - - numFramesOut++; - - // Render the latest frame now if frame pacing isn't in balanced mode - if (prefs.framePacing != PreferenceConfiguration.FRAME_PACING_BALANCED) { - // Get the last output buffer in the queue - while ((outIndex = videoDecoder.dequeueOutputBuffer(info, 0)) >= 0) { - videoDecoder.releaseOutputBuffer(lastIndex, false); - - numFramesOut++; - - lastIndex = outIndex; - presentationTimeUs = info.presentationTimeUs; - } - - if (prefs.framePacing == PreferenceConfiguration.FRAME_PACING_MAX_SMOOTHNESS || - prefs.framePacing == PreferenceConfiguration.FRAME_PACING_CAP_FPS) { - // In max smoothness or cap FPS mode, we want to never drop frames - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - // Use a PTS that will cause this frame to never be dropped - videoDecoder.releaseOutputBuffer(lastIndex, 0); - } - else { - videoDecoder.releaseOutputBuffer(lastIndex, true); - } - } - else { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - // Use a PTS that will cause this frame to be dropped if another comes in within - // the same V-sync period - videoDecoder.releaseOutputBuffer(lastIndex, System.nanoTime()); - } - else { - videoDecoder.releaseOutputBuffer(lastIndex, true); - } - } - - activeWindowVideoStats.totalFramesRendered++; - } - else { - // For balanced frame pacing case, the Choreographer callback will handle rendering. - // We just put all frames into the output buffer queue and let it handle things. - - // Discard the oldest buffer if we've exceeded our limit. - // - // NB: We have to do this on the producer side because the consumer may not - // run for a while (if there is a huge mismatch between stream FPS and display - // refresh rate). - if (outputBufferQueue.size() == OUTPUT_BUFFER_QUEUE_LIMIT) { - try { - videoDecoder.releaseOutputBuffer(outputBufferQueue.take(), false); - } catch (InterruptedException e) { - // We're shutting down, so we can just drop this buffer on the floor - // and it will be reclaimed when the codec is released. - return; - } - } - - // Add this buffer - outputBufferQueue.add(lastIndex); - } - - // Add delta time to the totals (excluding probable outliers) - long delta = SystemClock.uptimeMillis() - (presentationTimeUs / 1000); - if (delta >= 0 && delta < 1000) { - activeWindowVideoStats.decoderTimeMs += delta; - if (!USE_FRAME_RENDER_TIME) { - activeWindowVideoStats.totalTimeMs += delta; - } - } - } else { - switch (outIndex) { - case MediaCodec.INFO_TRY_AGAIN_LATER: - break; - case MediaCodec.INFO_OUTPUT_FORMAT_CHANGED: - LimeLog.info("Output format changed"); - outputFormat = videoDecoder.getOutputFormat(); - LimeLog.info("New output format: " + outputFormat); - break; - default: - break; - } - } - } catch (IllegalStateException e) { - handleDecoderException(e); - } finally { - doCodecRecoveryIfRequired(CR_FLAG_RENDER_THREAD); - } - } - } - }; - rendererThread.setName("Video - Renderer (MediaCodec)"); - rendererThread.setPriority(Thread.NORM_PRIORITY + 2); - rendererThread.start(); - } - - private boolean fetchNextInputBuffer() { - long startTime; - boolean codecRecovered; - - if (nextInputBuffer != null) { - // We already have an input buffer - return true; - } - - startTime = SystemClock.uptimeMillis(); - - try { - // If we don't have an input buffer index yet, fetch one now - while (nextInputBufferIndex < 0 && !stopping) { - nextInputBufferIndex = videoDecoder.dequeueInputBuffer(10000); - } - - // Get the backing ByteBuffer for the input buffer index - if (nextInputBufferIndex >= 0) { - // Using the new getInputBuffer() API on Lollipop allows - // the framework to do some performance optimizations for us - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - nextInputBuffer = videoDecoder.getInputBuffer(nextInputBufferIndex); - if (nextInputBuffer == null) { - // According to the Android docs, getInputBuffer() can return null "if the - // index is not a dequeued input buffer". I don't think this ever should - // happen but if it does, let's try to get a new input buffer next time. - nextInputBufferIndex = -1; - } - } - else { - nextInputBuffer = legacyInputBuffers[nextInputBufferIndex]; - - // Clear old input data pre-Lollipop - nextInputBuffer.clear(); - } - } - } catch (IllegalStateException e) { - handleDecoderException(e); - return false; - } finally { - codecRecovered = doCodecRecoveryIfRequired(CR_FLAG_INPUT_THREAD); - } - - // If codec recovery is required, always return false to ensure the caller will request - // an IDR frame to complete the codec recovery. - if (codecRecovered) { - return false; - } - - int deltaMs = (int)(SystemClock.uptimeMillis() - startTime); - - if (deltaMs >= 20) { - LimeLog.warning("Dequeue input buffer ran long: " + deltaMs + " ms"); - } - - if (nextInputBuffer == null) { - // We've been hung for 5 seconds and no other exception was reported, - // so generate a decoder hung exception - if (deltaMs >= 5000 && initialException == null) { - DecoderHungException decoderHungException = new DecoderHungException(deltaMs); - if (!reportedCrash) { - reportedCrash = true; - crashListener.notifyCrash(decoderHungException); - } - throw new RendererException(this, decoderHungException); - } - - return false; - } - - return true; - } - - @Override - public void start() { - startRendererThread(); - startChoreographerThread(); - } - - // !!! May be called even if setup()/start() fails !!! - public void prepareForStop() { - // Let the decoding code know to ignore codec exceptions now - stopping = true; - - // Halt the rendering thread - if (rendererThread != null) { - rendererThread.interrupt(); - } - - // Stop any active codec recovery operations - synchronized (codecRecoveryMonitor) { - codecRecoveryType.set(CR_RECOVERY_TYPE_NONE); - codecRecoveryMonitor.notifyAll(); - } - - // Post a quit message to the Choreographer looper (if we have one) - if (choreographerHandler != null) { - choreographerHandler.post(new Runnable() { - @Override - public void run() { - // Don't allow any further messages to be queued - choreographerHandlerThread.quit(); - - // Deregister the frame callback (if registered) - Choreographer.getInstance().removeFrameCallback(MediaCodecDecoderRenderer.this); - } - }); - } - } - - @Override - public void stop() { - // May be called already, but we'll call it now to be safe - prepareForStop(); - - // Wait for the Choreographer looper to shut down (if we have one) - if (choreographerHandlerThread != null) { - try { - choreographerHandlerThread.join(); - } catch (InterruptedException e) { - e.printStackTrace(); - - // InterruptedException clears the thread's interrupt status. Since we can't - // handle that here, we will re-interrupt the thread to set the interrupt - // status back to true. - Thread.currentThread().interrupt(); - } - } - - // Wait for the renderer thread to shut down - try { - rendererThread.join(); - } catch (InterruptedException e) { - e.printStackTrace(); - - // InterruptedException clears the thread's interrupt status. Since we can't - // handle that here, we will re-interrupt the thread to set the interrupt - // status back to true. - Thread.currentThread().interrupt(); - } - } - - @Override - public void cleanup() { - videoDecoder.release(); - } - - @Override - public void setHdrMode(boolean enabled, byte[] hdrMetadata) { - // HDR metadata is only supported in Android 7.0 and later, so don't bother - // restarting the codec on anything earlier than that. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - if (currentHdrMetadata != null && (!enabled || hdrMetadata == null)) { - currentHdrMetadata = null; - } - else if (enabled && hdrMetadata != null && !Arrays.equals(currentHdrMetadata, hdrMetadata)) { - currentHdrMetadata = hdrMetadata; - } - else { - // Nothing to do - return; - } - - // If we reach this point, we need to restart the MediaCodec instance to - // pick up the HDR metadata change. This will happen on the next input - // or output buffer. - - // HACK: Reset codec recovery attempt counter, since this is an expected "recovery" - codecRecoveryAttempts = 0; - - // Promote None/Flush to Restart and leave Reset alone - if (!codecRecoveryType.compareAndSet(CR_RECOVERY_TYPE_NONE, CR_RECOVERY_TYPE_RESTART)) { - codecRecoveryType.compareAndSet(CR_RECOVERY_TYPE_FLUSH, CR_RECOVERY_TYPE_RESTART); - } - } - } - - private boolean queueNextInputBuffer(long timestampUs, int codecFlags) { - boolean codecRecovered; - - try { - videoDecoder.queueInputBuffer(nextInputBufferIndex, - 0, nextInputBuffer.position(), - timestampUs, codecFlags); - - // We need a new buffer now - nextInputBufferIndex = -1; - nextInputBuffer = null; - } catch (IllegalStateException e) { - if (handleDecoderException(e)) { - // We encountered a transient error. In this case, just hold onto the buffer - // (to avoid leaking it), clear it, and keep it for the next frame. We'll return - // false to trigger an IDR frame to recover. - nextInputBuffer.clear(); - } - else { - // We encountered a non-transient error. In this case, we will simply leak the - // buffer because we cannot be sure we will ever succeed in queuing it. - nextInputBufferIndex = -1; - nextInputBuffer = null; - } - return false; - } finally { - codecRecovered = doCodecRecoveryIfRequired(CR_FLAG_INPUT_THREAD); - } - - // If codec recovery is required, always return false to ensure the caller will request - // an IDR frame to complete the codec recovery. - if (codecRecovered) { - return false; - } - - // Fetch a new input buffer now while we have some time between frames - // to have it ready immediately when the next frame arrives. - // - // We must propagate the return value here in order to properly handle - // codec recovery happening in fetchNextInputBuffer(). If we don't, we'll - // never get an IDR frame to complete the recovery process. - return fetchNextInputBuffer(); - } - - private void doProfileSpecificSpsPatching(SeqParameterSet sps) { - // Some devices benefit from setting constraint flags 4 & 5 to make this Constrained - // High Profile which allows the decoder to assume there will be no B-frames and - // reduce delay and buffering accordingly. Some devices (Marvell, Exynos 4) don't - // like it so we only set them on devices that are confirmed to benefit from it. - if (sps.profileIdc == 100 && constrainedHighProfile) { - LimeLog.info("Setting constraint set flags for constrained high profile"); - sps.constraintSet4Flag = true; - sps.constraintSet5Flag = true; - } - else { - // Force the constraints unset otherwise (some may be set by default) - sps.constraintSet4Flag = false; - sps.constraintSet5Flag = false; - } - } - - @SuppressWarnings("deprecation") - @Override - public int submitDecodeUnit(byte[] decodeUnitData, int decodeUnitLength, int decodeUnitType, - int frameNumber, int frameType, char frameHostProcessingLatency, - long receiveTimeMs, long enqueueTimeMs) { - if (stopping) { - // Don't bother if we're stopping - return MoonBridge.DR_OK; - } - - if (lastFrameNumber == 0) { - activeWindowVideoStats.measurementStartTimestamp = SystemClock.uptimeMillis(); - } else if (frameNumber != lastFrameNumber && frameNumber != lastFrameNumber + 1) { - // We can receive the same "frame" multiple times if it's an IDR frame. - // In that case, each frame start NALU is submitted independently. - activeWindowVideoStats.framesLost += frameNumber - lastFrameNumber - 1; - activeWindowVideoStats.totalFrames += frameNumber - lastFrameNumber - 1; - activeWindowVideoStats.frameLossEvents++; - } - - // Reset CSD data for each IDR frame - if (lastFrameNumber != frameNumber && frameType == MoonBridge.FRAME_TYPE_IDR) { - vpsBuffers.clear(); - spsBuffers.clear(); - ppsBuffers.clear(); - } - - lastFrameNumber = frameNumber; - - // Flip stats windows roughly every second - if (SystemClock.uptimeMillis() >= activeWindowVideoStats.measurementStartTimestamp + 1000) { - if (prefs.enablePerfOverlay) { - VideoStats lastTwo = new VideoStats(); - lastTwo.add(lastWindowVideoStats); - lastTwo.add(activeWindowVideoStats); - VideoStatsFps fps = lastTwo.getFps(); - String decoder; - - if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_H264) != 0) { - decoder = avcDecoder.getName(); - } else if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_H265) != 0) { - decoder = hevcDecoder.getName(); - } else if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_AV1) != 0) { - decoder = av1Decoder.getName(); - } else { - decoder = "(unknown)"; - } - - float decodeTimeMs = (float)lastTwo.decoderTimeMs / lastTwo.totalFramesReceived; - long rttInfo = MoonBridge.getEstimatedRttInfo(); - StringBuilder sb = new StringBuilder(); - sb.append(context.getString(R.string.perf_overlay_streamdetails, initialWidth + "x" + initialHeight, fps.totalFps)).append('\n'); - sb.append(context.getString(R.string.perf_overlay_decoder, decoder)).append('\n'); - sb.append(context.getString(R.string.perf_overlay_incomingfps, fps.receivedFps)).append('\n'); - sb.append(context.getString(R.string.perf_overlay_renderingfps, fps.renderedFps)).append('\n'); - sb.append(context.getString(R.string.perf_overlay_netdrops, - (float)lastTwo.framesLost / lastTwo.totalFrames * 100)).append('\n'); - sb.append(context.getString(R.string.perf_overlay_netlatency, - (int)(rttInfo >> 32), (int)rttInfo)).append('\n'); - if (lastTwo.framesWithHostProcessingLatency > 0) { - sb.append(context.getString(R.string.perf_overlay_hostprocessinglatency, - (float)lastTwo.minHostProcessingLatency / 10, - (float)lastTwo.maxHostProcessingLatency / 10, - (float)lastTwo.totalHostProcessingLatency / 10 / lastTwo.framesWithHostProcessingLatency)).append('\n'); - } - sb.append(context.getString(R.string.perf_overlay_dectime, decodeTimeMs)); - perfListener.onPerfUpdate(sb.toString()); - } - - globalVideoStats.add(activeWindowVideoStats); - lastWindowVideoStats.copy(activeWindowVideoStats); - activeWindowVideoStats.clear(); - activeWindowVideoStats.measurementStartTimestamp = SystemClock.uptimeMillis(); - } - - boolean csdSubmittedForThisFrame = false; - - // IDR frames require special handling for CSD buffer submission - if (frameType == MoonBridge.FRAME_TYPE_IDR) { - // H264 SPS - if (decodeUnitType == MoonBridge.BUFFER_TYPE_SPS && (videoFormat & MoonBridge.VIDEO_FORMAT_MASK_H264) != 0) { - numSpsIn++; - - ByteBuffer spsBuf = ByteBuffer.wrap(decodeUnitData); - int startSeqLen = decodeUnitData[2] == 0x01 ? 3 : 4; - - // Skip to the start of the NALU data - spsBuf.position(startSeqLen + 1); - - // The H264Utils.readSPS function safely handles - // Annex B NALUs (including NALUs with escape sequences) - SeqParameterSet sps = H264Utils.readSPS(spsBuf); - - // Some decoders rely on H264 level to decide how many buffers are needed - // Since we only need one frame buffered, we'll set the level as low as we can - // for known resolution combinations. Reference frame invalidation may need - // these, so leave them be for those decoders. - if (!refFrameInvalidationActive) { - if (initialWidth <= 720 && initialHeight <= 480 && refreshRate <= 60) { - // Max 5 buffered frames at 720x480x60 - LimeLog.info("Patching level_idc to 31"); - sps.levelIdc = 31; - } - else if (initialWidth <= 1280 && initialHeight <= 720 && refreshRate <= 60) { - // Max 5 buffered frames at 1280x720x60 - LimeLog.info("Patching level_idc to 32"); - sps.levelIdc = 32; - } - else if (initialWidth <= 1920 && initialHeight <= 1080 && refreshRate <= 60) { - // Max 4 buffered frames at 1920x1080x64 - LimeLog.info("Patching level_idc to 42"); - sps.levelIdc = 42; - } - else { - // Leave the profile alone (currently 5.0) - } - } - - // TI OMAP4 requires a reference frame count of 1 to decode successfully. Exynos 4 - // also requires this fixup. - // - // I'm doing this fixup for all devices because I haven't seen any devices that - // this causes issues for. At worst, it seems to do nothing and at best it fixes - // issues with video lag, hangs, and crashes. - // - // It does break reference frame invalidation, so we will not do that for decoders - // where we've enabled reference frame invalidation. - if (!refFrameInvalidationActive) { - LimeLog.info("Patching num_ref_frames in SPS"); - sps.numRefFrames = 1; - } - - // GFE 2.5.11 changed the SPS to add additional extensions. Some devices don't like these - // so we remove them here on old devices unless these devices also support HEVC. - // See getPreferredColorSpace() for further information. - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O && - sps.vuiParams != null && - hevcDecoder == null && - av1Decoder == null) { - sps.vuiParams.videoSignalTypePresentFlag = false; - sps.vuiParams.colourDescriptionPresentFlag = false; - sps.vuiParams.chromaLocInfoPresentFlag = false; - } - - // Some older devices used to choke on a bitstream restrictions, so we won't provide them - // unless explicitly whitelisted. For newer devices, leave the bitstream restrictions present. - if (needsSpsBitstreamFixup || isExynos4 || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - // The SPS that comes in the current H264 bytestream doesn't set bitstream_restriction_flag - // or max_dec_frame_buffering which increases decoding latency on Tegra. - - // If the encoder didn't include VUI parameters in the SPS, add them now - if (sps.vuiParams == null) { - LimeLog.info("Adding VUI parameters"); - sps.vuiParams = new VUIParameters(); - } - - // GFE 2.5.11 started sending bitstream restrictions - if (sps.vuiParams.bitstreamRestriction == null) { - LimeLog.info("Adding bitstream restrictions"); - sps.vuiParams.bitstreamRestriction = new VUIParameters.BitstreamRestriction(); - sps.vuiParams.bitstreamRestriction.motionVectorsOverPicBoundariesFlag = true; - sps.vuiParams.bitstreamRestriction.maxBytesPerPicDenom = 2; - sps.vuiParams.bitstreamRestriction.maxBitsPerMbDenom = 1; - sps.vuiParams.bitstreamRestriction.log2MaxMvLengthHorizontal = 16; - sps.vuiParams.bitstreamRestriction.log2MaxMvLengthVertical = 16; - sps.vuiParams.bitstreamRestriction.numReorderFrames = 0; - } - else { - LimeLog.info("Patching bitstream restrictions"); - } - - // Some devices throw errors if maxDecFrameBuffering < numRefFrames - sps.vuiParams.bitstreamRestriction.maxDecFrameBuffering = sps.numRefFrames; - - // These values are the defaults for the fields, but they are more aggressive - // than what GFE sends in 2.5.11, but it doesn't seem to cause picture problems. - // We'll leave these alone for "modern" devices just in case they care. - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - sps.vuiParams.bitstreamRestriction.maxBytesPerPicDenom = 2; - sps.vuiParams.bitstreamRestriction.maxBitsPerMbDenom = 1; - } - - // log2_max_mv_length_horizontal and log2_max_mv_length_vertical are set to more - // conservative values by GFE 2.5.11. We'll let those values stand. - } - else if (sps.vuiParams != null) { - // Devices that didn't/couldn't get bitstream restrictions before GFE 2.5.11 - // will continue to not receive them now - sps.vuiParams.bitstreamRestriction = null; - } - - // If we need to hack this SPS to say we're baseline, do so now - if (needsBaselineSpsHack) { - LimeLog.info("Hacking SPS to baseline"); - sps.profileIdc = 66; - savedSps = sps; - } - - // Patch the SPS constraint flags - doProfileSpecificSpsPatching(sps); - - // The H264Utils.writeSPS function safely handles - // Annex B NALUs (including NALUs with escape sequences) - ByteBuffer escapedNalu = H264Utils.writeSPS(sps, decodeUnitLength); - - // Construct the patched SPS - byte[] naluBuffer = new byte[startSeqLen + 1 + escapedNalu.limit()]; - System.arraycopy(decodeUnitData, 0, naluBuffer, 0, startSeqLen + 1); - escapedNalu.get(naluBuffer, startSeqLen + 1, escapedNalu.limit()); - - // Batch this to submit together with other CSD per AOSP docs - spsBuffers.add(naluBuffer); - return MoonBridge.DR_OK; - } - else if (decodeUnitType == MoonBridge.BUFFER_TYPE_VPS) { - numVpsIn++; - - // Batch this to submit together with other CSD per AOSP docs - byte[] naluBuffer = new byte[decodeUnitLength]; - System.arraycopy(decodeUnitData, 0, naluBuffer, 0, decodeUnitLength); - vpsBuffers.add(naluBuffer); - return MoonBridge.DR_OK; - } - // Only the HEVC SPS hits this path (H.264 is handled above) - else if (decodeUnitType == MoonBridge.BUFFER_TYPE_SPS) { - numSpsIn++; - - // Batch this to submit together with other CSD per AOSP docs - byte[] naluBuffer = new byte[decodeUnitLength]; - System.arraycopy(decodeUnitData, 0, naluBuffer, 0, decodeUnitLength); - spsBuffers.add(naluBuffer); - return MoonBridge.DR_OK; - } - else if (decodeUnitType == MoonBridge.BUFFER_TYPE_PPS) { - numPpsIn++; - - // Batch this to submit together with other CSD per AOSP docs - byte[] naluBuffer = new byte[decodeUnitLength]; - System.arraycopy(decodeUnitData, 0, naluBuffer, 0, decodeUnitLength); - ppsBuffers.add(naluBuffer); - return MoonBridge.DR_OK; - } - else if ((videoFormat & (MoonBridge.VIDEO_FORMAT_MASK_H264 | MoonBridge.VIDEO_FORMAT_MASK_H265)) != 0) { - // If this is the first CSD blob or we aren't supporting fused IDR frames, we will - // submit the CSD blob in a separate input buffer for each IDR frame. - if (!submittedCsd || !fusedIdrFrame) { - if (!fetchNextInputBuffer()) { - return MoonBridge.DR_NEED_IDR; - } - - // Submit all CSD when we receive the first non-CSD blob in an IDR frame - for (byte[] vpsBuffer : vpsBuffers) { - nextInputBuffer.put(vpsBuffer); - } - for (byte[] spsBuffer : spsBuffers) { - nextInputBuffer.put(spsBuffer); - } - for (byte[] ppsBuffer : ppsBuffers) { - nextInputBuffer.put(ppsBuffer); - } - - if (!queueNextInputBuffer(0, MediaCodec.BUFFER_FLAG_CODEC_CONFIG)) { - return MoonBridge.DR_NEED_IDR; - } - - // Remember that we already submitted CSD for this frame, so we don't do it - // again in the fused IDR case below. - csdSubmittedForThisFrame = true; - - // Remember that we submitted CSD globally for this MediaCodec instance - submittedCsd = true; - - if (needsBaselineSpsHack) { - needsBaselineSpsHack = false; - - if (!replaySps()) { - return MoonBridge.DR_NEED_IDR; - } - - LimeLog.info("SPS replay complete"); - } - } - } - } - - if (frameHostProcessingLatency != 0) { - if (activeWindowVideoStats.minHostProcessingLatency != 0) { - activeWindowVideoStats.minHostProcessingLatency = (char) Math.min(activeWindowVideoStats.minHostProcessingLatency, frameHostProcessingLatency); - } else { - activeWindowVideoStats.minHostProcessingLatency = frameHostProcessingLatency; - } - activeWindowVideoStats.framesWithHostProcessingLatency += 1; - } - activeWindowVideoStats.maxHostProcessingLatency = (char) Math.max(activeWindowVideoStats.maxHostProcessingLatency, frameHostProcessingLatency); - activeWindowVideoStats.totalHostProcessingLatency += frameHostProcessingLatency; - - activeWindowVideoStats.totalFramesReceived++; - activeWindowVideoStats.totalFrames++; - - if (!FRAME_RENDER_TIME_ONLY) { - // Count time from first packet received to enqueue time as receive time - // We will count DU queue time as part of decoding, because it is directly - // caused by a slow decoder. - activeWindowVideoStats.totalTimeMs += enqueueTimeMs - receiveTimeMs; - } - - if (!fetchNextInputBuffer()) { - return MoonBridge.DR_NEED_IDR; - } - - int codecFlags = 0; - - if (frameType == MoonBridge.FRAME_TYPE_IDR) { - codecFlags |= MediaCodec.BUFFER_FLAG_SYNC_FRAME; - - // If we are using fused IDR frames, submit the CSD with each IDR frame - if (fusedIdrFrame && !csdSubmittedForThisFrame) { - for (byte[] vpsBuffer : vpsBuffers) { - nextInputBuffer.put(vpsBuffer); - } - for (byte[] spsBuffer : spsBuffers) { - nextInputBuffer.put(spsBuffer); - } - for (byte[] ppsBuffer : ppsBuffers) { - nextInputBuffer.put(ppsBuffer); - } - } - } - - long timestampUs = enqueueTimeMs * 1000; - if (timestampUs <= lastTimestampUs) { - // We can't submit multiple buffers with the same timestamp - // so bump it up by one before queuing - timestampUs = lastTimestampUs + 1; - } - lastTimestampUs = timestampUs; - - numFramesIn++; - - if (decodeUnitLength > nextInputBuffer.limit() - nextInputBuffer.position()) { - IllegalArgumentException exception = new IllegalArgumentException( - "Decode unit length "+decodeUnitLength+" too large for input buffer "+nextInputBuffer.limit()); - if (!reportedCrash) { - reportedCrash = true; - crashListener.notifyCrash(exception); - } - throw new RendererException(this, exception); - } - - // Copy data from our buffer list into the input buffer - nextInputBuffer.put(decodeUnitData, 0, decodeUnitLength); - - if (!queueNextInputBuffer(timestampUs, codecFlags)) { - return MoonBridge.DR_NEED_IDR; - } - - return MoonBridge.DR_OK; - } - - private boolean replaySps() { - if (!fetchNextInputBuffer()) { - return false; - } - - // Write the Annex B header - nextInputBuffer.put(new byte[]{0x00, 0x00, 0x00, 0x01, 0x67}); - - // Switch the H264 profile back to high - savedSps.profileIdc = 100; - - // Patch the SPS constraint flags - doProfileSpecificSpsPatching(savedSps); - - // The H264Utils.writeSPS function safely handles - // Annex B NALUs (including NALUs with escape sequences) - ByteBuffer escapedNalu = H264Utils.writeSPS(savedSps, 128); - nextInputBuffer.put(escapedNalu); - - // No need for the SPS anymore - savedSps = null; - - // Queue the new SPS - return queueNextInputBuffer(0, MediaCodec.BUFFER_FLAG_CODEC_CONFIG); - } - - @Override - public int getCapabilities() { - int capabilities = 0; - - // Request the optimal number of slices per frame for this decoder - capabilities |= MoonBridge.CAPABILITY_SLICES_PER_FRAME(optimalSlicesPerFrame); - - // Enable reference frame invalidation on supported hardware - if (refFrameInvalidationAvc) { - capabilities |= MoonBridge.CAPABILITY_REFERENCE_FRAME_INVALIDATION_AVC; - } - if (refFrameInvalidationHevc) { - capabilities |= MoonBridge.CAPABILITY_REFERENCE_FRAME_INVALIDATION_HEVC; - } - if (refFrameInvalidationAv1) { - capabilities |= MoonBridge.CAPABILITY_REFERENCE_FRAME_INVALIDATION_AV1; - } - - // Enable direct submit on supported hardware - if (directSubmit) { - capabilities |= MoonBridge.CAPABILITY_DIRECT_SUBMIT; - } - - return capabilities; - } - - public int getAverageEndToEndLatency() { - if (globalVideoStats.totalFramesReceived == 0) { - return 0; - } - return (int)(globalVideoStats.totalTimeMs / globalVideoStats.totalFramesReceived); - } - - public int getAverageDecoderLatency() { - if (globalVideoStats.totalFramesReceived == 0) { - return 0; - } - return (int)(globalVideoStats.decoderTimeMs / globalVideoStats.totalFramesReceived); - } - - static class DecoderHungException extends RuntimeException { - private int hangTimeMs; - - DecoderHungException(int hangTimeMs) { - this.hangTimeMs = hangTimeMs; - } - - public String toString() { - String str = ""; - - str += "Hang time: "+hangTimeMs+" ms"+ RendererException.DELIMITER; - str += super.toString(); - - return str; - } - } - - static class RendererException extends RuntimeException { - private static final long serialVersionUID = 8985937536997012406L; - protected static final String DELIMITER = BuildConfig.DEBUG ? "\n" : " | "; - - private String text; - - RendererException(MediaCodecDecoderRenderer renderer, Exception e) { - this.text = generateText(renderer, e); - } - - public String toString() { - return text; - } - - private String generateText(MediaCodecDecoderRenderer renderer, Exception originalException) { - String str; - - if (renderer.numVpsIn == 0 && renderer.numSpsIn == 0 && renderer.numPpsIn == 0) { - str = "PreSPSError"; - } - else if (renderer.numSpsIn > 0 && renderer.numPpsIn == 0) { - str = "PrePPSError"; - } - else if (renderer.numPpsIn > 0 && renderer.numFramesIn == 0) { - str = "PreIFrameError"; - } - else if (renderer.numFramesIn > 0 && renderer.outputFormat == null) { - str = "PreOutputConfigError"; - } - else if (renderer.outputFormat != null && renderer.numFramesOut == 0) { - str = "PreOutputError"; - } - else if (renderer.numFramesOut <= renderer.refreshRate * 30) { - str = "EarlyOutputError"; - } - else { - str = "ErrorWhileStreaming"; - } - - str += "Format: "+String.format("%x", renderer.videoFormat)+DELIMITER; - str += "AVC Decoder: "+((renderer.avcDecoder != null) ? renderer.avcDecoder.getName():"(none)")+DELIMITER; - str += "HEVC Decoder: "+((renderer.hevcDecoder != null) ? renderer.hevcDecoder.getName():"(none)")+DELIMITER; - str += "AV1 Decoder: "+((renderer.av1Decoder != null) ? renderer.av1Decoder.getName():"(none)")+DELIMITER; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && renderer.avcDecoder != null) { - Range avcWidthRange = renderer.avcDecoder.getCapabilitiesForType("video/avc").getVideoCapabilities().getSupportedWidths(); - str += "AVC supported width range: "+avcWidthRange+DELIMITER; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - try { - Range avcFpsRange = renderer.avcDecoder.getCapabilitiesForType("video/avc").getVideoCapabilities().getAchievableFrameRatesFor(renderer.initialWidth, renderer.initialHeight); - str += "AVC achievable FPS range: "+avcFpsRange+DELIMITER; - } catch (IllegalArgumentException e) { - str += "AVC achievable FPS range: UNSUPPORTED!"+DELIMITER; - } - } - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && renderer.hevcDecoder != null) { - Range hevcWidthRange = renderer.hevcDecoder.getCapabilitiesForType("video/hevc").getVideoCapabilities().getSupportedWidths(); - str += "HEVC supported width range: "+hevcWidthRange+DELIMITER; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - try { - Range hevcFpsRange = renderer.hevcDecoder.getCapabilitiesForType("video/hevc").getVideoCapabilities().getAchievableFrameRatesFor(renderer.initialWidth, renderer.initialHeight); - str += "HEVC achievable FPS range: " + hevcFpsRange + DELIMITER; - } catch (IllegalArgumentException e) { - str += "HEVC achievable FPS range: UNSUPPORTED!"+DELIMITER; - } - } - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && renderer.av1Decoder != null) { - Range av1WidthRange = renderer.av1Decoder.getCapabilitiesForType("video/av01").getVideoCapabilities().getSupportedWidths(); - str += "AV1 supported width range: "+av1WidthRange+DELIMITER; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - try { - Range av1FpsRange = renderer.av1Decoder.getCapabilitiesForType("video/av01").getVideoCapabilities().getAchievableFrameRatesFor(renderer.initialWidth, renderer.initialHeight); - str += "AV1 achievable FPS range: " + av1FpsRange + DELIMITER; - } catch (IllegalArgumentException e) { - str += "AV1 achievable FPS range: UNSUPPORTED!"+DELIMITER; - } - } - } - str += "Configured format: "+renderer.configuredFormat+DELIMITER; - str += "Input format: "+renderer.inputFormat+DELIMITER; - str += "Output format: "+renderer.outputFormat+DELIMITER; - str += "Adaptive playback: "+renderer.adaptivePlayback+DELIMITER; - str += "GL Renderer: "+renderer.glRenderer+DELIMITER; - //str += "Build fingerprint: "+Build.FINGERPRINT+DELIMITER; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - str += "SOC: "+Build.SOC_MANUFACTURER+" - "+Build.SOC_MODEL+DELIMITER; - str += "Performance class: "+Build.VERSION.MEDIA_PERFORMANCE_CLASS+DELIMITER; - /*str += "Vendor params: "; - List params = renderer.videoDecoder.getSupportedVendorParameters(); - if (params.isEmpty()) { - str += "NONE"; - } - else { - for (String param : params) { - str += param + " "; - } - } - str += DELIMITER;*/ - } - str += "Consecutive crashes: "+renderer.consecutiveCrashCount+DELIMITER; - str += "RFI active: "+renderer.refFrameInvalidationActive+DELIMITER; - str += "Using modern SPS patching: "+(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)+DELIMITER; - str += "Fused IDR frames: "+renderer.fusedIdrFrame+DELIMITER; - str += "Video dimensions: "+renderer.initialWidth+"x"+renderer.initialHeight+DELIMITER; - str += "FPS target: "+renderer.refreshRate+DELIMITER; - str += "Bitrate: "+renderer.prefs.bitrate+" Kbps"+DELIMITER; - str += "CSD stats: "+renderer.numVpsIn+", "+renderer.numSpsIn+", "+renderer.numPpsIn+DELIMITER; - str += "Frames in-out: "+renderer.numFramesIn+", "+renderer.numFramesOut+DELIMITER; - str += "Total frames received: "+renderer.globalVideoStats.totalFramesReceived+DELIMITER; - str += "Total frames rendered: "+renderer.globalVideoStats.totalFramesRendered+DELIMITER; - str += "Frame losses: "+renderer.globalVideoStats.framesLost+" in "+renderer.globalVideoStats.frameLossEvents+" loss events"+DELIMITER; - str += "Average end-to-end client latency: "+renderer.getAverageEndToEndLatency()+"ms"+DELIMITER; - str += "Average hardware decoder latency: "+renderer.getAverageDecoderLatency()+"ms"+DELIMITER; - str += "Frame pacing mode: "+renderer.prefs.framePacing+DELIMITER; - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - if (originalException instanceof CodecException) { - CodecException ce = (CodecException) originalException; - - str += "Diagnostic Info: "+ce.getDiagnosticInfo()+DELIMITER; - str += "Recoverable: "+ce.isRecoverable()+DELIMITER; - str += "Transient: "+ce.isTransient()+DELIMITER; - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - str += "Codec Error Code: "+ce.getErrorCode()+DELIMITER; - } - } - } - - str += originalException.toString(); - - return str; - } - } -} +package com.limelight.binding.video; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.atomic.AtomicInteger; + +import org.jcodec.codecs.h264.H264Utils; +import org.jcodec.codecs.h264.io.model.SeqParameterSet; +import org.jcodec.codecs.h264.io.model.VUIParameters; + +import com.limelight.BuildConfig; +import com.limelight.LimeLog; +import com.limelight.R; +import com.limelight.nvstream.av.video.VideoDecoderRenderer; +import com.limelight.nvstream.jni.MoonBridge; +import com.limelight.preferences.PreferenceConfiguration; +import com.limelight.utils.TrafficStatsHelper; + +import android.annotation.TargetApi; +import android.app.Activity; +import android.content.Context; +import android.media.MediaCodec; +import android.media.MediaCodecInfo; +import android.media.MediaFormat; +import android.media.MediaCodec.BufferInfo; +import android.media.MediaCodec.CodecException; +import android.net.TrafficStats; +import android.os.Build; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Process; +import android.os.SystemClock; +import android.util.Range; +import android.view.Choreographer; +import android.view.Surface; +import android.view.SurfaceHolder; + +public class MediaCodecDecoderRenderer extends VideoDecoderRenderer implements Choreographer.FrameCallback { + + private static final boolean USE_FRAME_RENDER_TIME = false; + private static final boolean FRAME_RENDER_TIME_ONLY = USE_FRAME_RENDER_TIME && false; + + // Used on versions < 5.0 + private ByteBuffer[] legacyInputBuffers; + + private MediaCodecInfo avcDecoder; + private MediaCodecInfo hevcDecoder; + private MediaCodecInfo av1Decoder; + + private final ArrayList vpsBuffers = new ArrayList<>(); + private final ArrayList spsBuffers = new ArrayList<>(); + private final ArrayList ppsBuffers = new ArrayList<>(); + private boolean submittedCsd; + private byte[] currentHdrMetadata; + + private int nextInputBufferIndex = -1; + private ByteBuffer nextInputBuffer; + + private Context context; + private Activity activity; + private MediaCodec videoDecoder; + private Thread rendererThread; + private boolean needsSpsBitstreamFixup, isExynos4; + private boolean adaptivePlayback, directSubmit, fusedIdrFrame; + private boolean constrainedHighProfile; + private boolean refFrameInvalidationAvc, refFrameInvalidationHevc, refFrameInvalidationAv1; + private byte optimalSlicesPerFrame; + private boolean refFrameInvalidationActive; + private int initialWidth, initialHeight; + private int videoFormat; + private Surface renderTarget; + private volatile boolean stopping; + private CrashListener crashListener; + private boolean reportedCrash; + private int consecutiveCrashCount; + private String glRenderer; + private boolean foreground = true; + private PerfOverlayListener perfListener; + + private static final int CR_MAX_TRIES = 10; + private static final int CR_RECOVERY_TYPE_NONE = 0; + private static final int CR_RECOVERY_TYPE_FLUSH = 1; + private static final int CR_RECOVERY_TYPE_RESTART = 2; + private static final int CR_RECOVERY_TYPE_RESET = 3; + private AtomicInteger codecRecoveryType = new AtomicInteger(CR_RECOVERY_TYPE_NONE); + private final Object codecRecoveryMonitor = new Object(); + + // Each thread that touches the MediaCodec object or any associated buffers must have a flag + // here and must call doCodecRecoveryIfRequired() on a regular basis. + private static final int CR_FLAG_INPUT_THREAD = 0x1; + private static final int CR_FLAG_RENDER_THREAD = 0x2; + private static final int CR_FLAG_CHOREOGRAPHER = 0x4; + private static final int CR_FLAG_ALL = CR_FLAG_INPUT_THREAD | CR_FLAG_RENDER_THREAD | CR_FLAG_CHOREOGRAPHER; + private int codecRecoveryThreadQuiescedFlags = 0; + private int codecRecoveryAttempts = 0; + + private MediaFormat inputFormat; + private MediaFormat outputFormat; + private MediaFormat configuredFormat; + + private boolean needsBaselineSpsHack; + private SeqParameterSet savedSps; + + private RendererException initialException; + private long initialExceptionTimestamp; + private static final int EXCEPTION_REPORT_DELAY_MS = 3000; + + private VideoStats activeWindowVideoStats; + private VideoStats lastWindowVideoStats; + private VideoStats globalVideoStats; + + private long lastTimestampUs; + private int lastFrameNumber; + private int refreshRate; + private PreferenceConfiguration prefs; + + private long lastNetDataNum; + private LinkedBlockingQueue outputBufferQueue = new LinkedBlockingQueue<>(); + private static final int OUTPUT_BUFFER_QUEUE_LIMIT = 2; + private long lastRenderedFrameTimeNanos; + private HandlerThread choreographerHandlerThread; + private Handler choreographerHandler; + + private int numSpsIn; + private int numPpsIn; + private int numVpsIn; + private int numFramesIn; + private int numFramesOut; + + private MediaCodecInfo findAvcDecoder() { + MediaCodecInfo decoder = MediaCodecHelper.findProbableSafeDecoder("video/avc", MediaCodecInfo.CodecProfileLevel.AVCProfileHigh); + if (decoder == null) { + decoder = MediaCodecHelper.findFirstDecoder("video/avc"); + } + return decoder; + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + private boolean decoderCanMeetPerformancePoint(MediaCodecInfo.VideoCapabilities caps, PreferenceConfiguration prefs) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + MediaCodecInfo.VideoCapabilities.PerformancePoint targetPerfPoint = new MediaCodecInfo.VideoCapabilities.PerformancePoint(prefs.width, prefs.height, prefs.fps); + List perfPoints = caps.getSupportedPerformancePoints(); + if (perfPoints != null) { + for (MediaCodecInfo.VideoCapabilities.PerformancePoint perfPoint : perfPoints) { + // If we find a performance point that covers our target, we're good to go + if (perfPoint.covers(targetPerfPoint)) { + return true; + } + } + + // We had performance point data but none met the specified streaming settings + return false; + } + + // Fall-through to try the Android M API if there's no performance point data + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + try { + // We'll ask the decoder what it can do for us at this resolution and see if our + // requested frame rate falls below or inside the range of achievable frame rates. + Range fpsRange = caps.getAchievableFrameRatesFor(prefs.width, prefs.height); + if (fpsRange != null) { + return prefs.fps <= fpsRange.getUpper(); + } + + // Fall-through to try the Android L API if there's no performance point data + } catch (IllegalArgumentException e) { + // Video size not supported at any frame rate + return false; + } + } + + // As a last resort, we will use areSizeAndRateSupported() which is explicitly NOT a + // performance metric, but it can work at least for the purpose of determining if + // the codec is going to die when given a stream with the specified settings. + return caps.areSizeAndRateSupported(prefs.width, prefs.height, prefs.fps); + } + + private boolean decoderCanMeetPerformancePointWithHevcAndNotAvc(MediaCodecInfo hevcDecoderInfo, MediaCodecInfo avcDecoderInfo, PreferenceConfiguration prefs) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + MediaCodecInfo.VideoCapabilities avcCaps = avcDecoderInfo.getCapabilitiesForType("video/avc").getVideoCapabilities(); + MediaCodecInfo.VideoCapabilities hevcCaps = hevcDecoderInfo.getCapabilitiesForType("video/hevc").getVideoCapabilities(); + + return !decoderCanMeetPerformancePoint(avcCaps, prefs) && decoderCanMeetPerformancePoint(hevcCaps, prefs); + } + else { + // No performance data + return false; + } + } + + private boolean decoderCanMeetPerformancePointWithAv1AndNotHevc(MediaCodecInfo av1DecoderInfo, MediaCodecInfo hevcDecoderInfo, PreferenceConfiguration prefs) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + MediaCodecInfo.VideoCapabilities av1Caps = av1DecoderInfo.getCapabilitiesForType("video/av01").getVideoCapabilities(); + MediaCodecInfo.VideoCapabilities hevcCaps = hevcDecoderInfo.getCapabilitiesForType("video/hevc").getVideoCapabilities(); + + return !decoderCanMeetPerformancePoint(hevcCaps, prefs) && decoderCanMeetPerformancePoint(av1Caps, prefs); + } + else { + // No performance data + return false; + } + } + + private boolean decoderCanMeetPerformancePointWithAv1AndNotAvc(MediaCodecInfo av1DecoderInfo, MediaCodecInfo avcDecoderInfo, PreferenceConfiguration prefs) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + MediaCodecInfo.VideoCapabilities avcCaps = avcDecoderInfo.getCapabilitiesForType("video/avc").getVideoCapabilities(); + MediaCodecInfo.VideoCapabilities av1Caps = av1DecoderInfo.getCapabilitiesForType("video/av01").getVideoCapabilities(); + + return !decoderCanMeetPerformancePoint(avcCaps, prefs) && decoderCanMeetPerformancePoint(av1Caps, prefs); + } + else { + // No performance data + return false; + } + } + + private MediaCodecInfo findHevcDecoder(PreferenceConfiguration prefs, boolean meteredNetwork, boolean requestedHdr) { + // Don't return anything if H.264 is forced + if (prefs.videoFormat == PreferenceConfiguration.FormatOption.FORCE_H264) { + return null; + } + + // We don't try the first HEVC decoder. We'd rather fall back to hardware accelerated AVC instead + // + // We need HEVC Main profile, so we could pass that constant to findProbableSafeDecoder, however + // some decoders (at least Qualcomm's Snapdragon 805) don't properly report support + // for even required levels of HEVC. + MediaCodecInfo hevcDecoderInfo = MediaCodecHelper.findProbableSafeDecoder("video/hevc", -1); + if (hevcDecoderInfo != null) { + if (!MediaCodecHelper.decoderIsWhitelistedForHevc(hevcDecoderInfo)) { + LimeLog.info("Found HEVC decoder, but it's not whitelisted - "+hevcDecoderInfo.getName()); + + // Force HEVC enabled if the user asked for it + if (prefs.videoFormat == PreferenceConfiguration.FormatOption.FORCE_HEVC) { + LimeLog.info("Forcing HEVC enabled despite non-whitelisted decoder"); + } + // HDR implies HEVC forced on, since HEVCMain10HDR10 is required for HDR. + else if (requestedHdr) { + LimeLog.info("Forcing HEVC enabled for HDR streaming"); + } + // > 4K streaming also requires HEVC, so force it on there too. + else if (prefs.width > 4096 || prefs.height > 4096) { + LimeLog.info("Forcing HEVC enabled for over 4K streaming"); + } + // Use HEVC if the H.264 decoder is unable to meet the performance point + else if (avcDecoder != null && decoderCanMeetPerformancePointWithHevcAndNotAvc(hevcDecoderInfo, avcDecoder, prefs)) { + LimeLog.info("Using non-whitelisted HEVC decoder to meet performance point"); + } + else { + return null; + } + } + } + + return hevcDecoderInfo; + } + + private MediaCodecInfo findAv1Decoder(PreferenceConfiguration prefs) { + // For now, don't use AV1 unless explicitly requested + if (prefs.videoFormat != PreferenceConfiguration.FormatOption.FORCE_AV1) { + return null; + } + + MediaCodecInfo decoderInfo = MediaCodecHelper.findProbableSafeDecoder("video/av01", -1); + if (decoderInfo != null) { + if (!MediaCodecHelper.isDecoderWhitelistedForAv1(decoderInfo)) { + LimeLog.info("Found AV1 decoder, but it's not whitelisted - "+decoderInfo.getName()); + + // Force HEVC enabled if the user asked for it + if (prefs.videoFormat == PreferenceConfiguration.FormatOption.FORCE_AV1) { + LimeLog.info("Forcing AV1 enabled despite non-whitelisted decoder"); + } + // Use AV1 if the HEVC decoder is unable to meet the performance point + else if (hevcDecoder != null && decoderCanMeetPerformancePointWithAv1AndNotHevc(decoderInfo, hevcDecoder, prefs)) { + LimeLog.info("Using non-whitelisted AV1 decoder to meet performance point"); + } + // Use AV1 if the H.264 decoder is unable to meet the performance point and we have no HEVC decoder + else if (hevcDecoder == null && decoderCanMeetPerformancePointWithAv1AndNotAvc(decoderInfo, avcDecoder, prefs)) { + LimeLog.info("Using non-whitelisted AV1 decoder to meet performance point"); + } + else { + return null; + } + } + } + + return decoderInfo; + } + + public void setRenderTarget(Surface renderTarget) { + this.renderTarget = renderTarget; + } + + public MediaCodecDecoderRenderer(Activity activity, PreferenceConfiguration prefs, + CrashListener crashListener, int consecutiveCrashCount, + boolean meteredData, boolean requestedHdr, + String glRenderer, PerfOverlayListener perfListener) { + //dumpDecoders(); + + this.context = activity; + this.activity = activity; + this.prefs = prefs; + this.crashListener = crashListener; + this.consecutiveCrashCount = consecutiveCrashCount; + this.glRenderer = glRenderer; + this.perfListener = perfListener; + + this.activeWindowVideoStats = new VideoStats(); + this.lastWindowVideoStats = new VideoStats(); + this.globalVideoStats = new VideoStats(); + + avcDecoder = findAvcDecoder(); + if (avcDecoder != null) { + LimeLog.info("Selected AVC decoder: "+avcDecoder.getName()); + } + else { + LimeLog.warning("No AVC decoder found"); + } + + hevcDecoder = findHevcDecoder(prefs, meteredData, requestedHdr); + if (hevcDecoder != null) { + LimeLog.info("Selected HEVC decoder: "+hevcDecoder.getName()); + } + else { + LimeLog.info("No HEVC decoder found"); + } + + av1Decoder = findAv1Decoder(prefs); + if (av1Decoder != null) { + LimeLog.info("Selected AV1 decoder: "+av1Decoder.getName()); + } + else { + LimeLog.info("No AV1 decoder found"); + } + + // Set attributes that are queried in getCapabilities(). This must be done here + // because getCapabilities() may be called before setup() in current versions of the common + // library. The limitation of this is that we don't know whether we're using HEVC or AVC. + int avcOptimalSlicesPerFrame = 0; + int hevcOptimalSlicesPerFrame = 0; + if (avcDecoder != null) { + directSubmit = MediaCodecHelper.decoderCanDirectSubmit(avcDecoder.getName()); + refFrameInvalidationAvc = MediaCodecHelper.decoderSupportsRefFrameInvalidationAvc(avcDecoder.getName(), prefs.height); + avcOptimalSlicesPerFrame = MediaCodecHelper.getDecoderOptimalSlicesPerFrame(avcDecoder.getName()); + + if (directSubmit) { + LimeLog.info("Decoder "+avcDecoder.getName()+" will use direct submit"); + } + if (refFrameInvalidationAvc) { + LimeLog.info("Decoder "+avcDecoder.getName()+" will use reference frame invalidation for AVC"); + } + LimeLog.info("Decoder "+avcDecoder.getName()+" wants "+avcOptimalSlicesPerFrame+" slices per frame"); + } + + if (hevcDecoder != null) { + refFrameInvalidationHevc = MediaCodecHelper.decoderSupportsRefFrameInvalidationHevc(hevcDecoder); + hevcOptimalSlicesPerFrame = MediaCodecHelper.getDecoderOptimalSlicesPerFrame(hevcDecoder.getName()); + + if (refFrameInvalidationHevc) { + LimeLog.info("Decoder "+hevcDecoder.getName()+" will use reference frame invalidation for HEVC"); + } + + LimeLog.info("Decoder "+hevcDecoder.getName()+" wants "+hevcOptimalSlicesPerFrame+" slices per frame"); + } + + if (av1Decoder != null) { + refFrameInvalidationAv1 = MediaCodecHelper.decoderSupportsRefFrameInvalidationAv1(av1Decoder); + + if (refFrameInvalidationAv1) { + LimeLog.info("Decoder "+av1Decoder.getName()+" will use reference frame invalidation for AV1"); + } + } + + // Use the larger of the two slices per frame preferences + optimalSlicesPerFrame = (byte)Math.max(avcOptimalSlicesPerFrame, hevcOptimalSlicesPerFrame); + LimeLog.info("Requesting "+optimalSlicesPerFrame+" slices per frame"); + + if (consecutiveCrashCount % 2 == 1) { + refFrameInvalidationAvc = refFrameInvalidationHevc = false; + LimeLog.warning("Disabling RFI due to previous crash"); + } + } + + public boolean isHevcSupported() { + return hevcDecoder != null; + } + + public boolean isAvcSupported() { + return avcDecoder != null; + } + + public boolean isHevcMain10Hdr10Supported() { + if (hevcDecoder == null) { + return false; + } + + for (MediaCodecInfo.CodecProfileLevel profileLevel : hevcDecoder.getCapabilitiesForType("video/hevc").profileLevels) { + if (profileLevel.profile == MediaCodecInfo.CodecProfileLevel.HEVCProfileMain10HDR10) { + LimeLog.info("HEVC decoder "+hevcDecoder.getName()+" supports HEVC Main10 HDR10"); + return true; + } + } + + return false; + } + + public boolean isAv1Supported() { + return av1Decoder != null; + } + + public boolean isAv1Main10Supported() { + if (av1Decoder == null) { + return false; + } + + for (MediaCodecInfo.CodecProfileLevel profileLevel : av1Decoder.getCapabilitiesForType("video/av01").profileLevels) { + if (profileLevel.profile == MediaCodecInfo.CodecProfileLevel.AV1ProfileMain10HDR10) { + LimeLog.info("AV1 decoder "+av1Decoder.getName()+" supports AV1 Main 10 HDR10"); + return true; + } + } + + return false; + } + + public int getPreferredColorSpace() { + // Default to Rec 709 which is probably better supported on modern devices. + // + // We are sticking to Rec 601 on older devices unless the device has an HEVC decoder + // to avoid possible regressions (and they are < 5% of installed devices). If we have + // an HEVC decoder, we will use Rec 709 (even for H.264) since we can't choose a + // colorspace by codec (and it's probably safe to say a SoC with HEVC decoding is + // plenty modern enough to handle H.264 VUI colorspace info). + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O || hevcDecoder != null || av1Decoder != null) { + return MoonBridge.COLORSPACE_REC_709; + } + else { + return MoonBridge.COLORSPACE_REC_601; + } + } + + public int getPreferredColorRange() { + if (prefs.fullRange) { + return MoonBridge.COLOR_RANGE_FULL; + } + else { + return MoonBridge.COLOR_RANGE_LIMITED; + } + } + + public void notifyVideoForeground() { + foreground = true; + } + + public void notifyVideoBackground() { + foreground = false; + } + + public int getActiveVideoFormat() { + return this.videoFormat; + } + + private MediaFormat createBaseMediaFormat(String mimeType) { + MediaFormat videoFormat = MediaFormat.createVideoFormat(mimeType, initialWidth, initialHeight); + + // Avoid setting KEY_FRAME_RATE on Lollipop and earlier to reduce compatibility risk + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + videoFormat.setInteger(MediaFormat.KEY_FRAME_RATE, refreshRate); + } + + // Populate keys for adaptive playback + if (adaptivePlayback) { + videoFormat.setInteger(MediaFormat.KEY_MAX_WIDTH, initialWidth); + videoFormat.setInteger(MediaFormat.KEY_MAX_HEIGHT, initialHeight); + } + + // Android 7.0 adds color options to the MediaFormat + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + videoFormat.setInteger(MediaFormat.KEY_COLOR_RANGE, + getPreferredColorRange() == MoonBridge.COLOR_RANGE_FULL ? + MediaFormat.COLOR_RANGE_FULL : MediaFormat.COLOR_RANGE_LIMITED); + + // If the stream is HDR-capable, the decoder will detect transitions in color standards + // rather than us hardcoding them into the MediaFormat. + if ((getActiveVideoFormat() & MoonBridge.VIDEO_FORMAT_MASK_10BIT) == 0) { + // Set color format keys when not in HDR mode, since we know they won't change + videoFormat.setInteger(MediaFormat.KEY_COLOR_TRANSFER, MediaFormat.COLOR_TRANSFER_SDR_VIDEO); + switch (getPreferredColorSpace()) { + case MoonBridge.COLORSPACE_REC_601: + videoFormat.setInteger(MediaFormat.KEY_COLOR_STANDARD, MediaFormat.COLOR_STANDARD_BT601_NTSC); + break; + case MoonBridge.COLORSPACE_REC_709: + videoFormat.setInteger(MediaFormat.KEY_COLOR_STANDARD, MediaFormat.COLOR_STANDARD_BT709); + break; + case MoonBridge.COLORSPACE_REC_2020: + videoFormat.setInteger(MediaFormat.KEY_COLOR_STANDARD, MediaFormat.COLOR_STANDARD_BT2020); + break; + } + } + } + return videoFormat; + } + + private void configureAndStartDecoder(MediaFormat format) { + // Set HDR metadata if present + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + if (currentHdrMetadata != null) { + ByteBuffer hdrStaticInfo = ByteBuffer.allocate(25).order(ByteOrder.LITTLE_ENDIAN); + ByteBuffer hdrMetadata = ByteBuffer.wrap(currentHdrMetadata).order(ByteOrder.LITTLE_ENDIAN); + + // Create a HDMI Dynamic Range and Mastering InfoFrame as defined by CTA-861.3 + hdrStaticInfo.put((byte) 0); // Metadata type + hdrStaticInfo.putShort(hdrMetadata.getShort()); // RX + hdrStaticInfo.putShort(hdrMetadata.getShort()); // RY + hdrStaticInfo.putShort(hdrMetadata.getShort()); // GX + hdrStaticInfo.putShort(hdrMetadata.getShort()); // GY + hdrStaticInfo.putShort(hdrMetadata.getShort()); // BX + hdrStaticInfo.putShort(hdrMetadata.getShort()); // BY + hdrStaticInfo.putShort(hdrMetadata.getShort()); // White X + hdrStaticInfo.putShort(hdrMetadata.getShort()); // White Y + hdrStaticInfo.putShort(hdrMetadata.getShort()); // Max mastering luminance + hdrStaticInfo.putShort(hdrMetadata.getShort()); // Min mastering luminance + hdrStaticInfo.putShort(hdrMetadata.getShort()); // Max content luminance + hdrStaticInfo.putShort(hdrMetadata.getShort()); // Max frame average luminance + + hdrStaticInfo.rewind(); + format.setByteBuffer(MediaFormat.KEY_HDR_STATIC_INFO, hdrStaticInfo); + } + else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + format.removeKey(MediaFormat.KEY_HDR_STATIC_INFO); + } + } + + LimeLog.info("Configuring with format: "+format); + + videoDecoder.configure(format, renderTarget, null, 0); + + configuredFormat = format; + + // After reconfiguration, we must resubmit CSD buffers + submittedCsd = false; + vpsBuffers.clear(); + spsBuffers.clear(); + ppsBuffers.clear(); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + // This will contain the actual accepted input format attributes + inputFormat = videoDecoder.getInputFormat(); + LimeLog.info("Input format: "+inputFormat); + } + + videoDecoder.setVideoScalingMode(MediaCodec.VIDEO_SCALING_MODE_SCALE_TO_FIT); + + // Start the decoder + videoDecoder.start(); + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + legacyInputBuffers = videoDecoder.getInputBuffers(); + } + } + + private boolean tryConfigureDecoder(MediaCodecInfo selectedDecoderInfo, MediaFormat format, boolean throwOnCodecError) { + boolean configured = false; + try { + videoDecoder = MediaCodec.createByCodecName(selectedDecoderInfo.getName()); + configureAndStartDecoder(format); + LimeLog.info("Using codec " + selectedDecoderInfo.getName() + " for hardware decoding " + format.getString(MediaFormat.KEY_MIME)); + configured = true; + } catch (IllegalArgumentException e) { + e.printStackTrace(); + if (throwOnCodecError) { + throw e; + } + } catch (IllegalStateException e) { + e.printStackTrace(); + if (throwOnCodecError) { + throw e; + } + } catch (IOException e) { + e.printStackTrace(); + if (throwOnCodecError) { + throw new RuntimeException(e); + } + } finally { + if (!configured && videoDecoder != null) { + videoDecoder.release(); + videoDecoder = null; + } + } + return configured; + } + + public int initializeDecoder(boolean throwOnCodecError) { + String mimeType; + MediaCodecInfo selectedDecoderInfo; + + if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_H264) != 0) { + mimeType = "video/avc"; + selectedDecoderInfo = avcDecoder; + + if (avcDecoder == null) { + LimeLog.severe("No available AVC decoder!"); + return -1; + } + + if (initialWidth > 4096 || initialHeight > 4096) { + LimeLog.severe("> 4K streaming only supported on HEVC"); + return -1; + } + + // These fixups only apply to H264 decoders + needsSpsBitstreamFixup = MediaCodecHelper.decoderNeedsSpsBitstreamRestrictions(selectedDecoderInfo.getName()); + needsBaselineSpsHack = MediaCodecHelper.decoderNeedsBaselineSpsHack(selectedDecoderInfo.getName()); + constrainedHighProfile = MediaCodecHelper.decoderNeedsConstrainedHighProfile(selectedDecoderInfo.getName()); + isExynos4 = MediaCodecHelper.isExynos4Device(); + if (needsSpsBitstreamFixup) { + LimeLog.info("Decoder "+selectedDecoderInfo.getName()+" needs SPS bitstream restrictions fixup"); + } + if (needsBaselineSpsHack) { + LimeLog.info("Decoder "+selectedDecoderInfo.getName()+" needs baseline SPS hack"); + } + if (constrainedHighProfile) { + LimeLog.info("Decoder "+selectedDecoderInfo.getName()+" needs constrained high profile"); + } + if (isExynos4) { + LimeLog.info("Decoder "+selectedDecoderInfo.getName()+" is on Exynos 4"); + } + + refFrameInvalidationActive = refFrameInvalidationAvc; + } + else if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_H265) != 0) { + mimeType = "video/hevc"; + selectedDecoderInfo = hevcDecoder; + + if (hevcDecoder == null) { + LimeLog.severe("No available HEVC decoder!"); + return -2; + } + + refFrameInvalidationActive = refFrameInvalidationHevc; + } + else if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_AV1) != 0) { + mimeType = "video/av01"; + selectedDecoderInfo = av1Decoder; + + if (av1Decoder == null) { + LimeLog.severe("No available AV1 decoder!"); + return -2; + } + + refFrameInvalidationActive = refFrameInvalidationAv1; + } + else { + // Unknown format + LimeLog.severe("Unknown format"); + return -3; + } + adaptivePlayback = MediaCodecHelper.decoderSupportsAdaptivePlayback(selectedDecoderInfo, mimeType); + fusedIdrFrame = MediaCodecHelper.decoderSupportsFusedIdrFrame(selectedDecoderInfo, mimeType); + + for (int tryNumber = 0;; tryNumber++) { + LimeLog.info("Decoder configuration try: "+tryNumber); + + MediaFormat mediaFormat = createBaseMediaFormat(mimeType); + // This will try low latency options until we find one that works (or we give up). + boolean newFormat = MediaCodecHelper.setDecoderLowLatencyOptions(mediaFormat, selectedDecoderInfo, tryNumber); + //todo 色彩格式 +// MediaCodecInfo.CodecCapabilities codecCapabilities = selectedDecoderInfo.getCapabilitiesForType(mimeType); +// int[] colorFormats=codecCapabilities.colorFormats; +// for (int colorFormat : colorFormats) { +// LimeLog.info("Decoder configuration colorFormats: "+colorFormat); +// } + // Throw the underlying codec exception on the last attempt if the caller requested it + if (tryConfigureDecoder(selectedDecoderInfo, mediaFormat, !newFormat && throwOnCodecError)) { + // Success! + break; + } + + if (!newFormat) { + // We couldn't even configure a decoder without any low latency options + return -5; + } + } + + if (USE_FRAME_RENDER_TIME && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + videoDecoder.setOnFrameRenderedListener(new MediaCodec.OnFrameRenderedListener() { + @Override + public void onFrameRendered(MediaCodec mediaCodec, long presentationTimeUs, long renderTimeNanos) { + long delta = (renderTimeNanos / 1000000L) - (presentationTimeUs / 1000); + if (delta >= 0 && delta < 1000) { + if (USE_FRAME_RENDER_TIME) { + activeWindowVideoStats.totalTimeMs += delta; + } + } + } + }, null); + } + + return 0; + } + + @Override + public int setup(int format, int width, int height, int redrawRate) { + this.initialWidth = width; + this.initialHeight = height; + this.videoFormat = format; + this.refreshRate = redrawRate; + + return initializeDecoder(false); + } + + // All threads that interact with the MediaCodec instance must call this function regularly! + private boolean doCodecRecoveryIfRequired(int quiescenceFlag) { + // NB: We cannot check 'stopping' here because we could end up bailing in a partially + // quiesced state that will cause the quiesced threads to never wake up. + if (codecRecoveryType.get() == CR_RECOVERY_TYPE_NONE) { + // Common case + return false; + } + + // We need some sort of recovery, so quiesce all threads before starting that + synchronized (codecRecoveryMonitor) { + if (choreographerHandlerThread == null) { + // If we have no choreographer thread, we can just mark that as quiesced right now. + codecRecoveryThreadQuiescedFlags |= CR_FLAG_CHOREOGRAPHER; + } + + codecRecoveryThreadQuiescedFlags |= quiescenceFlag; + + // This is the final thread to quiesce, so let's perform the codec recovery now. + if (codecRecoveryThreadQuiescedFlags == CR_FLAG_ALL) { + // Input and output buffers are invalidated by stop() and reset(). + nextInputBuffer = null; + nextInputBufferIndex = -1; + outputBufferQueue.clear(); + + // If we just need a flush, do so now with all threads quiesced. + if (codecRecoveryType.get() == CR_RECOVERY_TYPE_FLUSH) { + LimeLog.warning("Flushing decoder"); + try { + videoDecoder.flush(); + codecRecoveryType.set(CR_RECOVERY_TYPE_NONE); + } catch (IllegalStateException e) { + e.printStackTrace(); + + // Something went wrong during the restart, let's use a bigger hammer + // and try a reset instead. + codecRecoveryType.set(CR_RECOVERY_TYPE_RESTART); + } + } + + // We don't count flushes as codec recovery attempts + if (codecRecoveryType.get() != CR_RECOVERY_TYPE_NONE) { + codecRecoveryAttempts++; + LimeLog.info("Codec recovery attempt: "+codecRecoveryAttempts); + } + + // For "recoverable" exceptions, we can just stop, reconfigure, and restart. + if (codecRecoveryType.get() == CR_RECOVERY_TYPE_RESTART) { + LimeLog.warning("Trying to restart decoder after CodecException"); + try { + videoDecoder.stop(); + configureAndStartDecoder(configuredFormat); + codecRecoveryType.set(CR_RECOVERY_TYPE_NONE); + } catch (IllegalArgumentException e) { + e.printStackTrace(); + + // Our Surface is probably invalid, so just stop + stopping = true; + codecRecoveryType.set(CR_RECOVERY_TYPE_NONE); + } catch (IllegalStateException e) { + e.printStackTrace(); + + // Something went wrong during the restart, let's use a bigger hammer + // and try a reset instead. + codecRecoveryType.set(CR_RECOVERY_TYPE_RESET); + } + } + + // For "non-recoverable" exceptions on L+, we can call reset() to recover + // without having to recreate the entire decoder again. + if (codecRecoveryType.get() == CR_RECOVERY_TYPE_RESET && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + LimeLog.warning("Trying to reset decoder after CodecException"); + try { + videoDecoder.reset(); + configureAndStartDecoder(configuredFormat); + codecRecoveryType.set(CR_RECOVERY_TYPE_NONE); + } catch (IllegalArgumentException e) { + e.printStackTrace(); + + // Our Surface is probably invalid, so just stop + stopping = true; + codecRecoveryType.set(CR_RECOVERY_TYPE_NONE); + } catch (IllegalStateException e) { + e.printStackTrace(); + + // Something went wrong during the reset, we'll have to resort to + // releasing and recreating the decoder now. + } + } + + // If we _still_ haven't managed to recover, go for the nuclear option and just + // throw away the old decoder and reinitialize a new one from scratch. + if (codecRecoveryType.get() == CR_RECOVERY_TYPE_RESET) { + LimeLog.warning("Trying to recreate decoder after CodecException"); + videoDecoder.release(); + + try { + int err = initializeDecoder(true); + if (err != 0) { + throw new IllegalStateException("Decoder reset failed: " + err); + } + codecRecoveryType.set(CR_RECOVERY_TYPE_NONE); + } catch (IllegalArgumentException e) { + e.printStackTrace(); + + // Our Surface is probably invalid, so just stop + stopping = true; + codecRecoveryType.set(CR_RECOVERY_TYPE_NONE); + } catch (IllegalStateException e) { + // If we failed to recover after all of these attempts, just crash + if (!reportedCrash) { + reportedCrash = true; + crashListener.notifyCrash(e); + } + throw new RendererException(this, e); + } + } + + // Wake all quiesced threads and allow them to begin work again + codecRecoveryThreadQuiescedFlags = 0; + codecRecoveryMonitor.notifyAll(); + } + else { + // If we haven't quiesced all threads yet, wait to be signalled after recovery. + // The final thread to be quiesced will handle the codec recovery. + while (codecRecoveryType.get() != CR_RECOVERY_TYPE_NONE) { + try { + LimeLog.info("Waiting to quiesce decoder threads: "+codecRecoveryThreadQuiescedFlags); + codecRecoveryMonitor.wait(1000); + } catch (InterruptedException e) { + e.printStackTrace(); + + // InterruptedException clears the thread's interrupt status. Since we can't + // handle that here, we will re-interrupt the thread to set the interrupt + // status back to true. + Thread.currentThread().interrupt(); + + break; + } + } + } + } + + return true; + } + + // Returns true if the exception is transient + private boolean handleDecoderException(IllegalStateException e) { + // Eat decoder exceptions if we're in the process of stopping + if (stopping) { + return false; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && e instanceof CodecException) { + CodecException codecExc = (CodecException) e; + + if (codecExc.isTransient()) { + // We'll let transient exceptions go + LimeLog.warning(codecExc.getDiagnosticInfo()); + return true; + } + + LimeLog.severe(codecExc.getDiagnosticInfo()); + + // We can attempt a recovery or reset at this stage to try to start decoding again + if (codecRecoveryAttempts < CR_MAX_TRIES) { + // If the exception is non-recoverable or we already require a reset, perform a reset. + // If we have no prior unrecoverable failure, we will try a restart instead. + if (codecExc.isRecoverable()) { + if (codecRecoveryType.compareAndSet(CR_RECOVERY_TYPE_NONE, CR_RECOVERY_TYPE_RESTART)) { + LimeLog.info("Decoder requires restart for recoverable CodecException"); + e.printStackTrace(); + } + else if (codecRecoveryType.compareAndSet(CR_RECOVERY_TYPE_FLUSH, CR_RECOVERY_TYPE_RESTART)) { + LimeLog.info("Decoder flush promoted to restart for recoverable CodecException"); + e.printStackTrace(); + } + else if (codecRecoveryType.get() != CR_RECOVERY_TYPE_RESET && codecRecoveryType.get() != CR_RECOVERY_TYPE_RESTART) { + throw new IllegalStateException("Unexpected codec recovery type: " + codecRecoveryType.get()); + } + } + else if (!codecExc.isRecoverable()) { + if (codecRecoveryType.compareAndSet(CR_RECOVERY_TYPE_NONE, CR_RECOVERY_TYPE_RESET)) { + LimeLog.info("Decoder requires reset for non-recoverable CodecException"); + e.printStackTrace(); + } + else if (codecRecoveryType.compareAndSet(CR_RECOVERY_TYPE_FLUSH, CR_RECOVERY_TYPE_RESET)) { + LimeLog.info("Decoder flush promoted to reset for non-recoverable CodecException"); + e.printStackTrace(); + } + else if (codecRecoveryType.compareAndSet(CR_RECOVERY_TYPE_RESTART, CR_RECOVERY_TYPE_RESET)) { + LimeLog.info("Decoder restart promoted to reset for non-recoverable CodecException"); + e.printStackTrace(); + } + else if (codecRecoveryType.get() != CR_RECOVERY_TYPE_RESET) { + throw new IllegalStateException("Unexpected codec recovery type: " + codecRecoveryType.get()); + } + } + + // The recovery will take place when all threads reach doCodecRecoveryIfRequired(). + return false; + } + } + else { + // IllegalStateException was primarily used prior to the introduction of CodecException. + // Recovery from this requires a full decoder reset. + // + // NB: CodecException is an IllegalStateException, so we must check for it first. + if (codecRecoveryAttempts < CR_MAX_TRIES) { + if (codecRecoveryType.compareAndSet(CR_RECOVERY_TYPE_NONE, CR_RECOVERY_TYPE_RESET)) { + LimeLog.info("Decoder requires reset for IllegalStateException"); + e.printStackTrace(); + } + else if (codecRecoveryType.compareAndSet(CR_RECOVERY_TYPE_FLUSH, CR_RECOVERY_TYPE_RESET)) { + LimeLog.info("Decoder flush promoted to reset for IllegalStateException"); + e.printStackTrace(); + } + else if (codecRecoveryType.compareAndSet(CR_RECOVERY_TYPE_RESTART, CR_RECOVERY_TYPE_RESET)) { + LimeLog.info("Decoder restart promoted to reset for IllegalStateException"); + e.printStackTrace(); + } + else if (codecRecoveryType.get() != CR_RECOVERY_TYPE_RESET) { + throw new IllegalStateException("Unexpected codec recovery type: " + codecRecoveryType.get()); + } + + return false; + } + } + + // Only throw if we're not in the middle of codec recovery + if (codecRecoveryType.get() == CR_RECOVERY_TYPE_NONE) { + // + // There seems to be a race condition with decoder/surface teardown causing some + // decoders to to throw IllegalStateExceptions even before 'stopping' is set. + // To workaround this while allowing real exceptions to propagate, we will eat the + // first exception. If we are still receiving exceptions 3 seconds later, we will + // throw the original exception again. + // + if (initialException != null) { + // This isn't the first time we've had an exception processing video + if (SystemClock.uptimeMillis() - initialExceptionTimestamp >= EXCEPTION_REPORT_DELAY_MS) { + // It's been over 3 seconds and we're still getting exceptions. Throw the original now. + if (!reportedCrash) { + reportedCrash = true; + crashListener.notifyCrash(initialException); + } + throw initialException; + } + } + else { + // This is the first exception we've hit + initialException = new RendererException(this, e); + initialExceptionTimestamp = SystemClock.uptimeMillis(); + } + } + + // Not transient + return false; + } + + @Override + public void doFrame(long frameTimeNanos) { + // Do nothing if we're stopping + if (stopping) { + return; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + frameTimeNanos -= activity.getWindowManager().getDefaultDisplay().getAppVsyncOffsetNanos(); + } + + // Don't render unless a new frame is due. This prevents microstutter when streaming + // at a frame rate that doesn't match the display (such as 60 FPS on 120 Hz). + long actualFrameTimeDeltaNs = frameTimeNanos - lastRenderedFrameTimeNanos; + long expectedFrameTimeDeltaNs = 800000000 / refreshRate; // within 80% of the next frame + if (actualFrameTimeDeltaNs >= expectedFrameTimeDeltaNs) { + // Render up to one frame when in frame pacing mode. + // + // NB: Since the queue limit is 2, we won't starve the decoder of output buffers + // by holding onto them for too long. This also ensures we will have that 1 extra + // frame of buffer to smooth over network/rendering jitter. + Integer nextOutputBuffer = outputBufferQueue.poll(); + if (nextOutputBuffer != null) { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + videoDecoder.releaseOutputBuffer(nextOutputBuffer, frameTimeNanos); + } + else { + videoDecoder.releaseOutputBuffer(nextOutputBuffer, true); + } + + lastRenderedFrameTimeNanos = frameTimeNanos; + activeWindowVideoStats.totalFramesRendered++; + } catch (IllegalStateException ignored) { + try { + // Try to avoid leaking the output buffer by releasing it without rendering + videoDecoder.releaseOutputBuffer(nextOutputBuffer, false); + } catch (IllegalStateException e) { + // This will leak nextOutputBuffer, but there's really nothing else we can do + e.printStackTrace(); + handleDecoderException(e); + } + } + } + } + + // Attempt codec recovery even if we have nothing to render right now. Recovery can still + // be required even if the codec died before giving any output. + doCodecRecoveryIfRequired(CR_FLAG_CHOREOGRAPHER); + + // Request another callback for next frame + Choreographer.getInstance().postFrameCallback(this); + } + + private void startChoreographerThread() { + if (prefs.framePacing != PreferenceConfiguration.FRAME_PACING_BALANCED) { + // Not using Choreographer in this pacing mode + return; + } + + // We use a separate thread to avoid any main thread delays from delaying rendering + choreographerHandlerThread = new HandlerThread("Video - Choreographer", Process.THREAD_PRIORITY_DEFAULT + Process.THREAD_PRIORITY_MORE_FAVORABLE); + choreographerHandlerThread.start(); + + // Start the frame callbacks + choreographerHandler = new Handler(choreographerHandlerThread.getLooper()); + choreographerHandler.post(new Runnable() { + @Override + public void run() { + Choreographer.getInstance().postFrameCallback(MediaCodecDecoderRenderer.this); + } + }); + } + + private void startRendererThread() + { + rendererThread = new Thread() { + @Override + public void run() { + BufferInfo info = new BufferInfo(); + while (!stopping) { + try { + // Try to output a frame + int outIndex = videoDecoder.dequeueOutputBuffer(info, 50000); + if (outIndex >= 0) { + long presentationTimeUs = info.presentationTimeUs; + int lastIndex = outIndex; + + numFramesOut++; + + // Render the latest frame now if frame pacing isn't in balanced mode + if (prefs.framePacing != PreferenceConfiguration.FRAME_PACING_BALANCED) { + // Get the last output buffer in the queue + while ((outIndex = videoDecoder.dequeueOutputBuffer(info, 0)) >= 0) { + videoDecoder.releaseOutputBuffer(lastIndex, false); + + numFramesOut++; + + lastIndex = outIndex; + presentationTimeUs = info.presentationTimeUs; + } + + if (prefs.framePacing == PreferenceConfiguration.FRAME_PACING_MAX_SMOOTHNESS || + prefs.framePacing == PreferenceConfiguration.FRAME_PACING_CAP_FPS) { + // In max smoothness or cap FPS mode, we want to never drop frames + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + // Use a PTS that will cause this frame to never be dropped + videoDecoder.releaseOutputBuffer(lastIndex, 0); + } + else { + videoDecoder.releaseOutputBuffer(lastIndex, true); + } + } + else { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + // Use a PTS that will cause this frame to be dropped if another comes in within + // the same V-sync period + videoDecoder.releaseOutputBuffer(lastIndex, System.nanoTime()); + } + else { + videoDecoder.releaseOutputBuffer(lastIndex, true); + } + } + + activeWindowVideoStats.totalFramesRendered++; + } + else { + // For balanced frame pacing case, the Choreographer callback will handle rendering. + // We just put all frames into the output buffer queue and let it handle things. + + // Discard the oldest buffer if we've exceeded our limit. + // + // NB: We have to do this on the producer side because the consumer may not + // run for a while (if there is a huge mismatch between stream FPS and display + // refresh rate). + if (outputBufferQueue.size() == OUTPUT_BUFFER_QUEUE_LIMIT) { + try { + videoDecoder.releaseOutputBuffer(outputBufferQueue.take(), false); + } catch (InterruptedException e) { + // We're shutting down, so we can just drop this buffer on the floor + // and it will be reclaimed when the codec is released. + return; + } + } + + // Add this buffer + outputBufferQueue.add(lastIndex); + } + + // Add delta time to the totals (excluding probable outliers) + long delta = SystemClock.uptimeMillis() - (presentationTimeUs / 1000); + if (delta >= 0 && delta < 1000) { + activeWindowVideoStats.decoderTimeMs += delta; + if (!USE_FRAME_RENDER_TIME) { + activeWindowVideoStats.totalTimeMs += delta; + } + } + } else { + switch (outIndex) { + case MediaCodec.INFO_TRY_AGAIN_LATER: + break; + case MediaCodec.INFO_OUTPUT_FORMAT_CHANGED: + LimeLog.info("Output format changed"); + outputFormat = videoDecoder.getOutputFormat(); + LimeLog.info("New output format: " + outputFormat); + break; + default: + break; + } + } + } catch (IllegalStateException e) { + handleDecoderException(e); + } finally { + doCodecRecoveryIfRequired(CR_FLAG_RENDER_THREAD); + } + } + } + }; + rendererThread.setName("Video - Renderer (MediaCodec)"); + rendererThread.setPriority(Thread.NORM_PRIORITY + 2); + rendererThread.start(); + } + + private boolean fetchNextInputBuffer() { + long startTime; + boolean codecRecovered; + + if (nextInputBuffer != null) { + // We already have an input buffer + return true; + } + + startTime = SystemClock.uptimeMillis(); + + try { + // If we don't have an input buffer index yet, fetch one now + while (nextInputBufferIndex < 0 && !stopping) { + nextInputBufferIndex = videoDecoder.dequeueInputBuffer(10000); + } + + // Get the backing ByteBuffer for the input buffer index + if (nextInputBufferIndex >= 0) { + // Using the new getInputBuffer() API on Lollipop allows + // the framework to do some performance optimizations for us + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + nextInputBuffer = videoDecoder.getInputBuffer(nextInputBufferIndex); + if (nextInputBuffer == null) { + // According to the Android docs, getInputBuffer() can return null "if the + // index is not a dequeued input buffer". I don't think this ever should + // happen but if it does, let's try to get a new input buffer next time. + nextInputBufferIndex = -1; + } + } + else { + nextInputBuffer = legacyInputBuffers[nextInputBufferIndex]; + + // Clear old input data pre-Lollipop + nextInputBuffer.clear(); + } + } + } catch (IllegalStateException e) { + handleDecoderException(e); + return false; + } finally { + codecRecovered = doCodecRecoveryIfRequired(CR_FLAG_INPUT_THREAD); + } + + // If codec recovery is required, always return false to ensure the caller will request + // an IDR frame to complete the codec recovery. + if (codecRecovered) { + return false; + } + + int deltaMs = (int)(SystemClock.uptimeMillis() - startTime); + + if (deltaMs >= 20) { + LimeLog.warning("Dequeue input buffer ran long: " + deltaMs + " ms"); + } + + if (nextInputBuffer == null) { + // We've been hung for 5 seconds and no other exception was reported, + // so generate a decoder hung exception + if (deltaMs >= 5000 && initialException == null) { + DecoderHungException decoderHungException = new DecoderHungException(deltaMs); + if (!reportedCrash) { + reportedCrash = true; + crashListener.notifyCrash(decoderHungException); + } + throw new RendererException(this, decoderHungException); + } + + return false; + } + + return true; + } + + @Override + public void start() { + startRendererThread(); + startChoreographerThread(); + } + + // !!! May be called even if setup()/start() fails !!! + public void prepareForStop() { + // Let the decoding code know to ignore codec exceptions now + stopping = true; + + // Halt the rendering thread + if (rendererThread != null) { + rendererThread.interrupt(); + } + + // Stop any active codec recovery operations + synchronized (codecRecoveryMonitor) { + codecRecoveryType.set(CR_RECOVERY_TYPE_NONE); + codecRecoveryMonitor.notifyAll(); + } + + // Post a quit message to the Choreographer looper (if we have one) + if (choreographerHandler != null) { + choreographerHandler.post(new Runnable() { + @Override + public void run() { + // Don't allow any further messages to be queued + choreographerHandlerThread.quit(); + + // Deregister the frame callback (if registered) + Choreographer.getInstance().removeFrameCallback(MediaCodecDecoderRenderer.this); + } + }); + } + } + + @Override + public void stop() { + // May be called already, but we'll call it now to be safe + prepareForStop(); + + // Wait for the Choreographer looper to shut down (if we have one) + if (choreographerHandlerThread != null) { + try { + choreographerHandlerThread.join(); + } catch (InterruptedException e) { + e.printStackTrace(); + + // InterruptedException clears the thread's interrupt status. Since we can't + // handle that here, we will re-interrupt the thread to set the interrupt + // status back to true. + Thread.currentThread().interrupt(); + } + } + + // Wait for the renderer thread to shut down + try { + rendererThread.join(); + } catch (InterruptedException e) { + e.printStackTrace(); + + // InterruptedException clears the thread's interrupt status. Since we can't + // handle that here, we will re-interrupt the thread to set the interrupt + // status back to true. + Thread.currentThread().interrupt(); + } + } + + @Override + public void cleanup() { + videoDecoder.release(); + } + + @Override + public void setHdrMode(boolean enabled, byte[] hdrMetadata) { + // HDR metadata is only supported in Android 7.0 and later, so don't bother + // restarting the codec on anything earlier than that. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + if (currentHdrMetadata != null && (!enabled || hdrMetadata == null)) { + currentHdrMetadata = null; + } + else if (enabled && hdrMetadata != null && !Arrays.equals(currentHdrMetadata, hdrMetadata)) { + currentHdrMetadata = hdrMetadata; + } + else { + // Nothing to do + return; + } + + // If we reach this point, we need to restart the MediaCodec instance to + // pick up the HDR metadata change. This will happen on the next input + // or output buffer. + + // HACK: Reset codec recovery attempt counter, since this is an expected "recovery" + codecRecoveryAttempts = 0; + + // Promote None/Flush to Restart and leave Reset alone + if (!codecRecoveryType.compareAndSet(CR_RECOVERY_TYPE_NONE, CR_RECOVERY_TYPE_RESTART)) { + codecRecoveryType.compareAndSet(CR_RECOVERY_TYPE_FLUSH, CR_RECOVERY_TYPE_RESTART); + } + } + } + + private boolean queueNextInputBuffer(long timestampUs, int codecFlags) { + boolean codecRecovered; + + try { + videoDecoder.queueInputBuffer(nextInputBufferIndex, + 0, nextInputBuffer.position(), + timestampUs, codecFlags); + + // We need a new buffer now + nextInputBufferIndex = -1; + nextInputBuffer = null; + } catch (IllegalStateException e) { + if (handleDecoderException(e)) { + // We encountered a transient error. In this case, just hold onto the buffer + // (to avoid leaking it), clear it, and keep it for the next frame. We'll return + // false to trigger an IDR frame to recover. + nextInputBuffer.clear(); + } + else { + // We encountered a non-transient error. In this case, we will simply leak the + // buffer because we cannot be sure we will ever succeed in queuing it. + nextInputBufferIndex = -1; + nextInputBuffer = null; + } + return false; + } finally { + codecRecovered = doCodecRecoveryIfRequired(CR_FLAG_INPUT_THREAD); + } + + // If codec recovery is required, always return false to ensure the caller will request + // an IDR frame to complete the codec recovery. + if (codecRecovered) { + return false; + } + + // Fetch a new input buffer now while we have some time between frames + // to have it ready immediately when the next frame arrives. + // + // We must propagate the return value here in order to properly handle + // codec recovery happening in fetchNextInputBuffer(). If we don't, we'll + // never get an IDR frame to complete the recovery process. + return fetchNextInputBuffer(); + } + + private void doProfileSpecificSpsPatching(SeqParameterSet sps) { + // Some devices benefit from setting constraint flags 4 & 5 to make this Constrained + // High Profile which allows the decoder to assume there will be no B-frames and + // reduce delay and buffering accordingly. Some devices (Marvell, Exynos 4) don't + // like it so we only set them on devices that are confirmed to benefit from it. + if (sps.profileIdc == 100 && constrainedHighProfile) { + LimeLog.info("Setting constraint set flags for constrained high profile"); + sps.constraintSet4Flag = true; + sps.constraintSet5Flag = true; + } + else { + // Force the constraints unset otherwise (some may be set by default) + sps.constraintSet4Flag = false; + sps.constraintSet5Flag = false; + } + } + + @SuppressWarnings("deprecation") + @Override + public int submitDecodeUnit(byte[] decodeUnitData, int decodeUnitLength, int decodeUnitType, + int frameNumber, int frameType, char frameHostProcessingLatency, + long receiveTimeMs, long enqueueTimeMs) { + if (stopping) { + // Don't bother if we're stopping + return MoonBridge.DR_OK; + } + + if (lastFrameNumber == 0) { + activeWindowVideoStats.measurementStartTimestamp = SystemClock.uptimeMillis(); + } else if (frameNumber != lastFrameNumber && frameNumber != lastFrameNumber + 1) { + // We can receive the same "frame" multiple times if it's an IDR frame. + // In that case, each frame start NALU is submitted independently. + activeWindowVideoStats.framesLost += frameNumber - lastFrameNumber - 1; + activeWindowVideoStats.totalFrames += frameNumber - lastFrameNumber - 1; + activeWindowVideoStats.frameLossEvents++; + } + + // Reset CSD data for each IDR frame + if (lastFrameNumber != frameNumber && frameType == MoonBridge.FRAME_TYPE_IDR) { + vpsBuffers.clear(); + spsBuffers.clear(); + ppsBuffers.clear(); + } + + lastFrameNumber = frameNumber; + + // Flip stats windows roughly every second + if (SystemClock.uptimeMillis() >= activeWindowVideoStats.measurementStartTimestamp + 1000) { + if (prefs.enablePerfOverlay) { + VideoStats lastTwo = new VideoStats(); + lastTwo.add(lastWindowVideoStats); + lastTwo.add(activeWindowVideoStats); + VideoStatsFps fps = lastTwo.getFps(); + String decoder; + + if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_H264) != 0) { + decoder = avcDecoder.getName(); + } else if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_H265) != 0) { + decoder = hevcDecoder.getName(); + } else if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_AV1) != 0) { + decoder = av1Decoder.getName(); + } else { + decoder = "(unknown)"; + } + + float decodeTimeMs = (float)lastTwo.decoderTimeMs / lastTwo.totalFramesReceived; + long rttInfo = MoonBridge.getEstimatedRttInfo(); + StringBuilder sb = new StringBuilder(); + if(prefs.enablePerfOverlayLite){ + if(TrafficStatsHelper.getPackageRxBytes(Process.myUid())!= TrafficStats.UNSUPPORTED){ + long netData=TrafficStatsHelper.getPackageRxBytes(Process.myUid())+TrafficStatsHelper.getPackageTxBytes(Process.myUid()); + if(lastNetDataNum!=0){ + sb.append("带宽:"); + float realtimeNetData=(netData-lastNetDataNum)/1024f; + if(realtimeNetData>=1000){ + sb.append(String.format("%.2f", realtimeNetData/1024f) +"M/s\t "); + }else{ + sb.append(String.format("%.2f", realtimeNetData) +"K/s\t "); + } + } + lastNetDataNum=netData; + } +// sb.append("分辨率:"); +// sb.append(initialWidth + "x" + initialHeight); + sb.append("延迟/解码:"); + sb.append(context.getString(R.string.perf_overlay_lite_net,(int)(rttInfo >> 32))); + sb.append(" / "); + sb.append(context.getString(R.string.perf_overlay_lite_dectime,decodeTimeMs)); + sb.append("\t 丢包率:"); + sb.append(context.getString(R.string.perf_overlay_lite_netdrops,(float)lastTwo.framesLost / lastTwo.totalFrames * 100)); + sb.append("\t FPS:"); + sb.append(context.getString(R.string.perf_overlay_lite_fps,fps.totalFps)); +// sb.append("\n"); +// sb.append(context.getString(R.string.perf_overlay_lite_decoder,decoder)); + }else{ + sb.append(context.getString(R.string.perf_overlay_streamdetails, initialWidth + "x" + initialHeight, fps.totalFps)).append('\n'); + sb.append(context.getString(R.string.perf_overlay_decoder, decoder)).append('\n'); + sb.append(context.getString(R.string.perf_overlay_incomingfps, fps.receivedFps)).append('\n'); + sb.append(context.getString(R.string.perf_overlay_renderingfps, fps.renderedFps)).append('\n'); + sb.append(context.getString(R.string.perf_overlay_netdrops, + (float)lastTwo.framesLost / lastTwo.totalFrames * 100)).append('\n'); + sb.append(context.getString(R.string.perf_overlay_netlatency, + (int)(rttInfo >> 32), (int)rttInfo)).append('\n'); + if (lastTwo.framesWithHostProcessingLatency > 0) { + sb.append(context.getString(R.string.perf_overlay_hostprocessinglatency, + (float)lastTwo.minHostProcessingLatency / 10, + (float)lastTwo.maxHostProcessingLatency / 10, + (float)lastTwo.totalHostProcessingLatency / 10 / lastTwo.framesWithHostProcessingLatency)).append('\n'); + } + sb.append(context.getString(R.string.perf_overlay_dectime, decodeTimeMs)); + } + + perfListener.onPerfUpdate(sb.toString()); + } + + globalVideoStats.add(activeWindowVideoStats); + lastWindowVideoStats.copy(activeWindowVideoStats); + activeWindowVideoStats.clear(); + activeWindowVideoStats.measurementStartTimestamp = SystemClock.uptimeMillis(); + } + + boolean csdSubmittedForThisFrame = false; + + // IDR frames require special handling for CSD buffer submission + if (frameType == MoonBridge.FRAME_TYPE_IDR) { + // H264 SPS + if (decodeUnitType == MoonBridge.BUFFER_TYPE_SPS && (videoFormat & MoonBridge.VIDEO_FORMAT_MASK_H264) != 0) { + numSpsIn++; + + ByteBuffer spsBuf = ByteBuffer.wrap(decodeUnitData); + int startSeqLen = decodeUnitData[2] == 0x01 ? 3 : 4; + + // Skip to the start of the NALU data + spsBuf.position(startSeqLen + 1); + + // The H264Utils.readSPS function safely handles + // Annex B NALUs (including NALUs with escape sequences) + SeqParameterSet sps = H264Utils.readSPS(spsBuf); + + // Some decoders rely on H264 level to decide how many buffers are needed + // Since we only need one frame buffered, we'll set the level as low as we can + // for known resolution combinations. Reference frame invalidation may need + // these, so leave them be for those decoders. + if (!refFrameInvalidationActive) { + if (initialWidth <= 720 && initialHeight <= 480 && refreshRate <= 60) { + // Max 5 buffered frames at 720x480x60 + LimeLog.info("Patching level_idc to 31"); + sps.levelIdc = 31; + } + else if (initialWidth <= 1280 && initialHeight <= 720 && refreshRate <= 60) { + // Max 5 buffered frames at 1280x720x60 + LimeLog.info("Patching level_idc to 32"); + sps.levelIdc = 32; + } + else if (initialWidth <= 1920 && initialHeight <= 1080 && refreshRate <= 60) { + // Max 4 buffered frames at 1920x1080x64 + LimeLog.info("Patching level_idc to 42"); + sps.levelIdc = 42; + } + else { + // Leave the profile alone (currently 5.0) + } + } + + // TI OMAP4 requires a reference frame count of 1 to decode successfully. Exynos 4 + // also requires this fixup. + // + // I'm doing this fixup for all devices because I haven't seen any devices that + // this causes issues for. At worst, it seems to do nothing and at best it fixes + // issues with video lag, hangs, and crashes. + // + // It does break reference frame invalidation, so we will not do that for decoders + // where we've enabled reference frame invalidation. + if (!refFrameInvalidationActive) { + LimeLog.info("Patching num_ref_frames in SPS"); + sps.numRefFrames = 1; + } + + // GFE 2.5.11 changed the SPS to add additional extensions. Some devices don't like these + // so we remove them here on old devices unless these devices also support HEVC. + // See getPreferredColorSpace() for further information. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O && + sps.vuiParams != null && + hevcDecoder == null && + av1Decoder == null) { + sps.vuiParams.videoSignalTypePresentFlag = false; + sps.vuiParams.colourDescriptionPresentFlag = false; + sps.vuiParams.chromaLocInfoPresentFlag = false; + } + + // Some older devices used to choke on a bitstream restrictions, so we won't provide them + // unless explicitly whitelisted. For newer devices, leave the bitstream restrictions present. + if (needsSpsBitstreamFixup || isExynos4 || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // The SPS that comes in the current H264 bytestream doesn't set bitstream_restriction_flag + // or max_dec_frame_buffering which increases decoding latency on Tegra. + + // If the encoder didn't include VUI parameters in the SPS, add them now + if (sps.vuiParams == null) { + LimeLog.info("Adding VUI parameters"); + sps.vuiParams = new VUIParameters(); + } + + // GFE 2.5.11 started sending bitstream restrictions + if (sps.vuiParams.bitstreamRestriction == null) { + LimeLog.info("Adding bitstream restrictions"); + sps.vuiParams.bitstreamRestriction = new VUIParameters.BitstreamRestriction(); + sps.vuiParams.bitstreamRestriction.motionVectorsOverPicBoundariesFlag = true; + sps.vuiParams.bitstreamRestriction.maxBytesPerPicDenom = 2; + sps.vuiParams.bitstreamRestriction.maxBitsPerMbDenom = 1; + sps.vuiParams.bitstreamRestriction.log2MaxMvLengthHorizontal = 16; + sps.vuiParams.bitstreamRestriction.log2MaxMvLengthVertical = 16; + sps.vuiParams.bitstreamRestriction.numReorderFrames = 0; + } + else { + LimeLog.info("Patching bitstream restrictions"); + } + + // Some devices throw errors if maxDecFrameBuffering < numRefFrames + sps.vuiParams.bitstreamRestriction.maxDecFrameBuffering = sps.numRefFrames; + + // These values are the defaults for the fields, but they are more aggressive + // than what GFE sends in 2.5.11, but it doesn't seem to cause picture problems. + // We'll leave these alone for "modern" devices just in case they care. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + sps.vuiParams.bitstreamRestriction.maxBytesPerPicDenom = 2; + sps.vuiParams.bitstreamRestriction.maxBitsPerMbDenom = 1; + } + + // log2_max_mv_length_horizontal and log2_max_mv_length_vertical are set to more + // conservative values by GFE 2.5.11. We'll let those values stand. + } + else if (sps.vuiParams != null) { + // Devices that didn't/couldn't get bitstream restrictions before GFE 2.5.11 + // will continue to not receive them now + sps.vuiParams.bitstreamRestriction = null; + } + + // If we need to hack this SPS to say we're baseline, do so now + if (needsBaselineSpsHack) { + LimeLog.info("Hacking SPS to baseline"); + sps.profileIdc = 66; + savedSps = sps; + } + + // Patch the SPS constraint flags + doProfileSpecificSpsPatching(sps); + + // The H264Utils.writeSPS function safely handles + // Annex B NALUs (including NALUs with escape sequences) + ByteBuffer escapedNalu = H264Utils.writeSPS(sps, decodeUnitLength); + + // Construct the patched SPS + byte[] naluBuffer = new byte[startSeqLen + 1 + escapedNalu.limit()]; + System.arraycopy(decodeUnitData, 0, naluBuffer, 0, startSeqLen + 1); + escapedNalu.get(naluBuffer, startSeqLen + 1, escapedNalu.limit()); + + // Batch this to submit together with other CSD per AOSP docs + spsBuffers.add(naluBuffer); + return MoonBridge.DR_OK; + } + else if (decodeUnitType == MoonBridge.BUFFER_TYPE_VPS) { + numVpsIn++; + + // Batch this to submit together with other CSD per AOSP docs + byte[] naluBuffer = new byte[decodeUnitLength]; + System.arraycopy(decodeUnitData, 0, naluBuffer, 0, decodeUnitLength); + vpsBuffers.add(naluBuffer); + return MoonBridge.DR_OK; + } + // Only the HEVC SPS hits this path (H.264 is handled above) + else if (decodeUnitType == MoonBridge.BUFFER_TYPE_SPS) { + numSpsIn++; + + // Batch this to submit together with other CSD per AOSP docs + byte[] naluBuffer = new byte[decodeUnitLength]; + System.arraycopy(decodeUnitData, 0, naluBuffer, 0, decodeUnitLength); + spsBuffers.add(naluBuffer); + return MoonBridge.DR_OK; + } + else if (decodeUnitType == MoonBridge.BUFFER_TYPE_PPS) { + numPpsIn++; + + // Batch this to submit together with other CSD per AOSP docs + byte[] naluBuffer = new byte[decodeUnitLength]; + System.arraycopy(decodeUnitData, 0, naluBuffer, 0, decodeUnitLength); + ppsBuffers.add(naluBuffer); + return MoonBridge.DR_OK; + } + else if ((videoFormat & (MoonBridge.VIDEO_FORMAT_MASK_H264 | MoonBridge.VIDEO_FORMAT_MASK_H265)) != 0) { + // If this is the first CSD blob or we aren't supporting fused IDR frames, we will + // submit the CSD blob in a separate input buffer for each IDR frame. + if (!submittedCsd || !fusedIdrFrame) { + if (!fetchNextInputBuffer()) { + return MoonBridge.DR_NEED_IDR; + } + + // Submit all CSD when we receive the first non-CSD blob in an IDR frame + for (byte[] vpsBuffer : vpsBuffers) { + nextInputBuffer.put(vpsBuffer); + } + for (byte[] spsBuffer : spsBuffers) { + nextInputBuffer.put(spsBuffer); + } + for (byte[] ppsBuffer : ppsBuffers) { + nextInputBuffer.put(ppsBuffer); + } + + if (!queueNextInputBuffer(0, MediaCodec.BUFFER_FLAG_CODEC_CONFIG)) { + return MoonBridge.DR_NEED_IDR; + } + + // Remember that we already submitted CSD for this frame, so we don't do it + // again in the fused IDR case below. + csdSubmittedForThisFrame = true; + + // Remember that we submitted CSD globally for this MediaCodec instance + submittedCsd = true; + + if (needsBaselineSpsHack) { + needsBaselineSpsHack = false; + + if (!replaySps()) { + return MoonBridge.DR_NEED_IDR; + } + + LimeLog.info("SPS replay complete"); + } + } + } + } + + if (frameHostProcessingLatency != 0) { + if (activeWindowVideoStats.minHostProcessingLatency != 0) { + activeWindowVideoStats.minHostProcessingLatency = (char) Math.min(activeWindowVideoStats.minHostProcessingLatency, frameHostProcessingLatency); + } else { + activeWindowVideoStats.minHostProcessingLatency = frameHostProcessingLatency; + } + activeWindowVideoStats.framesWithHostProcessingLatency += 1; + } + activeWindowVideoStats.maxHostProcessingLatency = (char) Math.max(activeWindowVideoStats.maxHostProcessingLatency, frameHostProcessingLatency); + activeWindowVideoStats.totalHostProcessingLatency += frameHostProcessingLatency; + + activeWindowVideoStats.totalFramesReceived++; + activeWindowVideoStats.totalFrames++; + + if (!FRAME_RENDER_TIME_ONLY) { + // Count time from first packet received to enqueue time as receive time + // We will count DU queue time as part of decoding, because it is directly + // caused by a slow decoder. + activeWindowVideoStats.totalTimeMs += enqueueTimeMs - receiveTimeMs; + } + + if (!fetchNextInputBuffer()) { + return MoonBridge.DR_NEED_IDR; + } + + int codecFlags = 0; + + if (frameType == MoonBridge.FRAME_TYPE_IDR) { + codecFlags |= MediaCodec.BUFFER_FLAG_SYNC_FRAME; + + // If we are using fused IDR frames, submit the CSD with each IDR frame + if (fusedIdrFrame && !csdSubmittedForThisFrame) { + for (byte[] vpsBuffer : vpsBuffers) { + nextInputBuffer.put(vpsBuffer); + } + for (byte[] spsBuffer : spsBuffers) { + nextInputBuffer.put(spsBuffer); + } + for (byte[] ppsBuffer : ppsBuffers) { + nextInputBuffer.put(ppsBuffer); + } + } + } + + long timestampUs = enqueueTimeMs * 1000; + if (timestampUs <= lastTimestampUs) { + // We can't submit multiple buffers with the same timestamp + // so bump it up by one before queuing + timestampUs = lastTimestampUs + 1; + } + lastTimestampUs = timestampUs; + + numFramesIn++; + + if (decodeUnitLength > nextInputBuffer.limit() - nextInputBuffer.position()) { + IllegalArgumentException exception = new IllegalArgumentException( + "Decode unit length "+decodeUnitLength+" too large for input buffer "+nextInputBuffer.limit()); + if (!reportedCrash) { + reportedCrash = true; + crashListener.notifyCrash(exception); + } + throw new RendererException(this, exception); + } + + // Copy data from our buffer list into the input buffer + nextInputBuffer.put(decodeUnitData, 0, decodeUnitLength); + + if (!queueNextInputBuffer(timestampUs, codecFlags)) { + return MoonBridge.DR_NEED_IDR; + } + + return MoonBridge.DR_OK; + } + + private boolean replaySps() { + if (!fetchNextInputBuffer()) { + return false; + } + + // Write the Annex B header + nextInputBuffer.put(new byte[]{0x00, 0x00, 0x00, 0x01, 0x67}); + + // Switch the H264 profile back to high + savedSps.profileIdc = 100; + + // Patch the SPS constraint flags + doProfileSpecificSpsPatching(savedSps); + + // The H264Utils.writeSPS function safely handles + // Annex B NALUs (including NALUs with escape sequences) + ByteBuffer escapedNalu = H264Utils.writeSPS(savedSps, 128); + nextInputBuffer.put(escapedNalu); + + // No need for the SPS anymore + savedSps = null; + + // Queue the new SPS + return queueNextInputBuffer(0, MediaCodec.BUFFER_FLAG_CODEC_CONFIG); + } + + @Override + public int getCapabilities() { + int capabilities = 0; + + // Request the optimal number of slices per frame for this decoder + capabilities |= MoonBridge.CAPABILITY_SLICES_PER_FRAME(optimalSlicesPerFrame); + + // Enable reference frame invalidation on supported hardware + if (refFrameInvalidationAvc) { + capabilities |= MoonBridge.CAPABILITY_REFERENCE_FRAME_INVALIDATION_AVC; + } + if (refFrameInvalidationHevc) { + capabilities |= MoonBridge.CAPABILITY_REFERENCE_FRAME_INVALIDATION_HEVC; + } + if (refFrameInvalidationAv1) { + capabilities |= MoonBridge.CAPABILITY_REFERENCE_FRAME_INVALIDATION_AV1; + } + + // Enable direct submit on supported hardware + if (directSubmit) { + capabilities |= MoonBridge.CAPABILITY_DIRECT_SUBMIT; + } + + return capabilities; + } + + public int getAverageEndToEndLatency() { + if (globalVideoStats.totalFramesReceived == 0) { + return 0; + } + return (int)(globalVideoStats.totalTimeMs / globalVideoStats.totalFramesReceived); + } + + public int getAverageDecoderLatency() { + if (globalVideoStats.totalFramesReceived == 0) { + return 0; + } + return (int)(globalVideoStats.decoderTimeMs / globalVideoStats.totalFramesReceived); + } + + static class DecoderHungException extends RuntimeException { + private int hangTimeMs; + + DecoderHungException(int hangTimeMs) { + this.hangTimeMs = hangTimeMs; + } + + public String toString() { + String str = ""; + + str += "Hang time: "+hangTimeMs+" ms"+ RendererException.DELIMITER; + str += super.toString(); + + return str; + } + } + + static class RendererException extends RuntimeException { + private static final long serialVersionUID = 8985937536997012406L; + protected static final String DELIMITER = BuildConfig.DEBUG ? "\n" : " | "; + + private String text; + + RendererException(MediaCodecDecoderRenderer renderer, Exception e) { + this.text = generateText(renderer, e); + } + + public String toString() { + return text; + } + + private String generateText(MediaCodecDecoderRenderer renderer, Exception originalException) { + String str; + + if (renderer.numVpsIn == 0 && renderer.numSpsIn == 0 && renderer.numPpsIn == 0) { + str = "PreSPSError"; + } + else if (renderer.numSpsIn > 0 && renderer.numPpsIn == 0) { + str = "PrePPSError"; + } + else if (renderer.numPpsIn > 0 && renderer.numFramesIn == 0) { + str = "PreIFrameError"; + } + else if (renderer.numFramesIn > 0 && renderer.outputFormat == null) { + str = "PreOutputConfigError"; + } + else if (renderer.outputFormat != null && renderer.numFramesOut == 0) { + str = "PreOutputError"; + } + else if (renderer.numFramesOut <= renderer.refreshRate * 30) { + str = "EarlyOutputError"; + } + else { + str = "ErrorWhileStreaming"; + } + + str += "Format: "+String.format("%x", renderer.videoFormat)+DELIMITER; + str += "AVC Decoder: "+((renderer.avcDecoder != null) ? renderer.avcDecoder.getName():"(none)")+DELIMITER; + str += "HEVC Decoder: "+((renderer.hevcDecoder != null) ? renderer.hevcDecoder.getName():"(none)")+DELIMITER; + str += "AV1 Decoder: "+((renderer.av1Decoder != null) ? renderer.av1Decoder.getName():"(none)")+DELIMITER; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && renderer.avcDecoder != null) { + Range avcWidthRange = renderer.avcDecoder.getCapabilitiesForType("video/avc").getVideoCapabilities().getSupportedWidths(); + str += "AVC supported width range: "+avcWidthRange+DELIMITER; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + try { + Range avcFpsRange = renderer.avcDecoder.getCapabilitiesForType("video/avc").getVideoCapabilities().getAchievableFrameRatesFor(renderer.initialWidth, renderer.initialHeight); + str += "AVC achievable FPS range: "+avcFpsRange+DELIMITER; + } catch (IllegalArgumentException e) { + str += "AVC achievable FPS range: UNSUPPORTED!"+DELIMITER; + } + } + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && renderer.hevcDecoder != null) { + Range hevcWidthRange = renderer.hevcDecoder.getCapabilitiesForType("video/hevc").getVideoCapabilities().getSupportedWidths(); + str += "HEVC supported width range: "+hevcWidthRange+DELIMITER; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + try { + Range hevcFpsRange = renderer.hevcDecoder.getCapabilitiesForType("video/hevc").getVideoCapabilities().getAchievableFrameRatesFor(renderer.initialWidth, renderer.initialHeight); + str += "HEVC achievable FPS range: " + hevcFpsRange + DELIMITER; + } catch (IllegalArgumentException e) { + str += "HEVC achievable FPS range: UNSUPPORTED!"+DELIMITER; + } + } + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && renderer.av1Decoder != null) { + Range av1WidthRange = renderer.av1Decoder.getCapabilitiesForType("video/av01").getVideoCapabilities().getSupportedWidths(); + str += "AV1 supported width range: "+av1WidthRange+DELIMITER; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + try { + Range av1FpsRange = renderer.av1Decoder.getCapabilitiesForType("video/av01").getVideoCapabilities().getAchievableFrameRatesFor(renderer.initialWidth, renderer.initialHeight); + str += "AV1 achievable FPS range: " + av1FpsRange + DELIMITER; + } catch (IllegalArgumentException e) { + str += "AV1 achievable FPS range: UNSUPPORTED!"+DELIMITER; + } + } + } + str += "Configured format: "+renderer.configuredFormat+DELIMITER; + str += "Input format: "+renderer.inputFormat+DELIMITER; + str += "Output format: "+renderer.outputFormat+DELIMITER; + str += "Adaptive playback: "+renderer.adaptivePlayback+DELIMITER; + str += "GL Renderer: "+renderer.glRenderer+DELIMITER; + //str += "Build fingerprint: "+Build.FINGERPRINT+DELIMITER; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + str += "SOC: "+Build.SOC_MANUFACTURER+" - "+Build.SOC_MODEL+DELIMITER; + str += "Performance class: "+Build.VERSION.MEDIA_PERFORMANCE_CLASS+DELIMITER; + /*str += "Vendor params: "; + List params = renderer.videoDecoder.getSupportedVendorParameters(); + if (params.isEmpty()) { + str += "NONE"; + } + else { + for (String param : params) { + str += param + " "; + } + } + str += DELIMITER;*/ + } + str += "Consecutive crashes: "+renderer.consecutiveCrashCount+DELIMITER; + str += "RFI active: "+renderer.refFrameInvalidationActive+DELIMITER; + str += "Using modern SPS patching: "+(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)+DELIMITER; + str += "Fused IDR frames: "+renderer.fusedIdrFrame+DELIMITER; + str += "Video dimensions: "+renderer.initialWidth+"x"+renderer.initialHeight+DELIMITER; + str += "FPS target: "+renderer.refreshRate+DELIMITER; + str += "Bitrate: "+renderer.prefs.bitrate+" Kbps"+DELIMITER; + str += "CSD stats: "+renderer.numVpsIn+", "+renderer.numSpsIn+", "+renderer.numPpsIn+DELIMITER; + str += "Frames in-out: "+renderer.numFramesIn+", "+renderer.numFramesOut+DELIMITER; + str += "Total frames received: "+renderer.globalVideoStats.totalFramesReceived+DELIMITER; + str += "Total frames rendered: "+renderer.globalVideoStats.totalFramesRendered+DELIMITER; + str += "Frame losses: "+renderer.globalVideoStats.framesLost+" in "+renderer.globalVideoStats.frameLossEvents+" loss events"+DELIMITER; + str += "Average end-to-end client latency: "+renderer.getAverageEndToEndLatency()+"ms"+DELIMITER; + str += "Average hardware decoder latency: "+renderer.getAverageDecoderLatency()+"ms"+DELIMITER; + str += "Frame pacing mode: "+renderer.prefs.framePacing+DELIMITER; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + if (originalException instanceof CodecException) { + CodecException ce = (CodecException) originalException; + + str += "Diagnostic Info: "+ce.getDiagnosticInfo()+DELIMITER; + str += "Recoverable: "+ce.isRecoverable()+DELIMITER; + str += "Transient: "+ce.isTransient()+DELIMITER; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + str += "Codec Error Code: "+ce.getErrorCode()+DELIMITER; + } + } + } + + str += originalException.toString(); + + return str; + } + } +} diff --git a/app/src/main/java/com/limelight/binding/video/MediaCodecHelper.java b/app/src/main/java/com/limelight/binding/video/MediaCodecHelper.java old mode 100644 new mode 100755 index 24d1f01ab0..f85592539f --- a/app/src/main/java/com/limelight/binding/video/MediaCodecHelper.java +++ b/app/src/main/java/com/limelight/binding/video/MediaCodecHelper.java @@ -1,1017 +1,1017 @@ -package com.limelight.binding.video; - -import java.io.BufferedReader; -import java.io.File; -import java.io.FileReader; -import java.util.Collections; -import java.util.LinkedList; -import java.util.List; -import java.util.Locale; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import android.annotation.SuppressLint; -import android.app.ActivityManager; -import android.content.Context; -import android.content.pm.ConfigurationInfo; -import android.media.MediaCodec; -import android.media.MediaCodecInfo; -import android.media.MediaCodecList; -import android.media.MediaCodecInfo.CodecCapabilities; -import android.media.MediaCodecInfo.CodecProfileLevel; -import android.media.MediaFormat; -import android.os.Build; - -import com.limelight.LimeLog; -import com.limelight.preferences.PreferenceConfiguration; - -public class MediaCodecHelper { - - private static final List preferredDecoders; - - private static final List blacklistedDecoderPrefixes; - private static final List spsFixupBitstreamFixupDecoderPrefixes; - private static final List blacklistedAdaptivePlaybackPrefixes; - private static final List baselineProfileHackPrefixes; - private static final List directSubmitPrefixes; - private static final List constrainedHighProfilePrefixes; - private static final List whitelistedHevcDecoders; - private static final List refFrameInvalidationAvcPrefixes; - private static final List refFrameInvalidationHevcPrefixes; - private static final List useFourSlicesPrefixes; - private static final List qualcommDecoderPrefixes; - private static final List kirinDecoderPrefixes; - private static final List exynosDecoderPrefixes; - private static final List amlogicDecoderPrefixes; - private static final List knownVendorLowLatencyOptions; - - public static final boolean SHOULD_BYPASS_SOFTWARE_BLOCK = - Build.HARDWARE.equals("ranchu") || Build.HARDWARE.equals("cheets") || Build.BRAND.equals("Android-x86"); - - private static boolean isLowEndSnapdragon = false; - private static boolean isAdreno620 = false; - private static boolean initialized = false; - - static { - directSubmitPrefixes = new LinkedList<>(); - - // These decoders have low enough input buffer latency that they - // can be directly invoked from the receive thread - directSubmitPrefixes.add("omx.qcom"); - directSubmitPrefixes.add("omx.sec"); - directSubmitPrefixes.add("omx.exynos"); - directSubmitPrefixes.add("omx.intel"); - directSubmitPrefixes.add("omx.brcm"); - directSubmitPrefixes.add("omx.TI"); - directSubmitPrefixes.add("omx.arc"); - directSubmitPrefixes.add("omx.nvidia"); - - // All Codec2 decoders - directSubmitPrefixes.add("c2."); - } - - static { - refFrameInvalidationAvcPrefixes = new LinkedList<>(); - - refFrameInvalidationHevcPrefixes = new LinkedList<>(); - refFrameInvalidationHevcPrefixes.add("omx.exynos"); - refFrameInvalidationHevcPrefixes.add("c2.exynos"); - - // Qualcomm and NVIDIA may be added at runtime - } - - static { - preferredDecoders = new LinkedList<>(); - } - - static { - blacklistedDecoderPrefixes = new LinkedList<>(); - - // Blacklist software decoders that don't support H264 high profile except on systems - // that are expected to only have software decoders (like emulators). - if (!SHOULD_BYPASS_SOFTWARE_BLOCK) { - blacklistedDecoderPrefixes.add("omx.google"); - blacklistedDecoderPrefixes.add("AVCDecoder"); - - // We want to avoid ffmpeg decoders since they're usually software decoders, - // but we'll defer to the Android 10 isSoftwareOnly() API on newer devices - // to determine if we should use these or not. - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - blacklistedDecoderPrefixes.add("OMX.ffmpeg"); - } - } - - // Force these decoders disabled because: - // 1) They are software decoders, so the performance is terrible - // 2) They crash with our HEVC stream anyway (at least prior to CSD batching) - blacklistedDecoderPrefixes.add("OMX.qcom.video.decoder.hevcswvdec"); - blacklistedDecoderPrefixes.add("OMX.SEC.hevc.sw.dec"); - } - - static { - // If a decoder qualifies for reference frame invalidation, - // these entries will be ignored for those decoders. - spsFixupBitstreamFixupDecoderPrefixes = new LinkedList<>(); - spsFixupBitstreamFixupDecoderPrefixes.add("omx.nvidia"); - spsFixupBitstreamFixupDecoderPrefixes.add("omx.qcom"); - spsFixupBitstreamFixupDecoderPrefixes.add("omx.brcm"); - - baselineProfileHackPrefixes = new LinkedList<>(); - baselineProfileHackPrefixes.add("omx.intel"); - - blacklistedAdaptivePlaybackPrefixes = new LinkedList<>(); - // The Intel decoder on Lollipop on Nexus Player would increase latency badly - // if adaptive playback was enabled so let's avoid it to be safe. - blacklistedAdaptivePlaybackPrefixes.add("omx.intel"); - // The MediaTek decoder crashes at 1080p when adaptive playback is enabled - // on some Android TV devices with HEVC only. - blacklistedAdaptivePlaybackPrefixes.add("omx.mtk"); - - constrainedHighProfilePrefixes = new LinkedList<>(); - constrainedHighProfilePrefixes.add("omx.intel"); - } - - static { - whitelistedHevcDecoders = new LinkedList<>(); - - // Allow software HEVC decoding in the official AOSP emulator - if (Build.HARDWARE.equals("ranchu")) { - whitelistedHevcDecoders.add("omx.google"); - } - - // Exynos seems to be the only HEVC decoder that works reliably - whitelistedHevcDecoders.add("omx.exynos"); - - // On Darcy (Shield 2017), HEVC runs fine with no fixups required. For some reason, - // other X1 implementations require bitstream fixups. However, since numReferenceFrames - // has been supported in GFE since late 2017, we'll go ahead and enable HEVC for all - // device models. - // - // NVIDIA does partial HEVC acceleration on the Shield Tablet. I don't know - // whether the performance is good enough to use for streaming, but they're - // using the same omx.nvidia.h265.decode name as the Shield TV which has a - // fully accelerated HEVC pipeline. AFAIK, the only K1 devices with this - // partially accelerated HEVC decoder are the Shield Tablet and Xiaomi MiPad, - // so I'll check for those here. - // - // In case there are some that I missed, I will also exclude pre-Oreo OSes since - // only Shield ATV got an Oreo update and any newer Tegra devices will not ship - // with an old OS like Nougat. - if (!Build.DEVICE.equalsIgnoreCase("shieldtablet") && - !Build.DEVICE.equalsIgnoreCase("mocha") && - Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - whitelistedHevcDecoders.add("omx.nvidia"); - } - - // Plot twist: On newer Sony devices (BRAVIA_ATV2, BRAVIA_ATV3_4K, BRAVIA_UR1_4K) the H.264 decoder crashes - // on several configurations (> 60 FPS and 1440p) that work with HEVC, so we'll whitelist those devices for HEVC. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && Build.DEVICE.startsWith("BRAVIA_")) { - whitelistedHevcDecoders.add("omx.mtk"); - } - - // Amlogic requires 1 reference frame for HEVC to avoid hanging. Since it's been years - // since GFE added support for maxNumReferenceFrames, we'll just enable all Amlogic SoCs - // running Android 9 or later. - // - // NB: We don't do this on Sabrina (GCWGTV) because H.264 is lower latency when we use - // vendor.low-latency.enable. We will still use HEVC if decoderCanMeetPerformancePointWithHevcAndNotAvc() - // determines it's the only way to meet the performance requirements. - // - // With the Android 12 update, Sabrina now uses HEVC (with RFI) based upon FEATURE_LowLatency - // support, which provides equivalent latency to H.264 now. - // - // FIXME: Should we do this for all Amlogic S905X SoCs? - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && !Build.DEVICE.equalsIgnoreCase("sabrina")) { - whitelistedHevcDecoders.add("omx.amlogic"); - } - - // Realtek SoCs are used inside many Android TV devices and can only do 4K60 with HEVC. - // We'll enable those HEVC decoders by default and see if anything breaks. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - whitelistedHevcDecoders.add("omx.realtek"); - } - - // These theoretically have good HEVC decoding capabilities (potentially better than - // their AVC decoders), but haven't been tested enough - //whitelistedHevcDecoders.add("omx.rk"); - - // Let's see if HEVC decoders are finally stable with C2 - whitelistedHevcDecoders.add("c2."); - - // Based on GPU attributes queried at runtime, the omx.qcom/c2.qti prefix will be added - // during initialization to avoid SoCs with broken HEVC decoders. - } - - static { - useFourSlicesPrefixes = new LinkedList<>(); - - // Software decoders will use 4 slices per frame to allow for slice multithreading - useFourSlicesPrefixes.add("omx.google"); - useFourSlicesPrefixes.add("AVCDecoder"); - useFourSlicesPrefixes.add("omx.ffmpeg"); - useFourSlicesPrefixes.add("c2.android"); - - // Old Qualcomm decoders are detected at runtime - } - - static { - knownVendorLowLatencyOptions = new LinkedList<>(); - - knownVendorLowLatencyOptions.add("vendor.qti-ext-dec-low-latency.enable"); - knownVendorLowLatencyOptions.add("vendor.hisi-ext-low-latency-video-dec.video-scene-for-low-latency-req"); - knownVendorLowLatencyOptions.add("vendor.rtc-ext-dec-low-latency.enable"); - knownVendorLowLatencyOptions.add("vendor.low-latency.enable"); - } - - static { - qualcommDecoderPrefixes = new LinkedList<>(); - - qualcommDecoderPrefixes.add("omx.qcom"); - qualcommDecoderPrefixes.add("c2.qti"); - } - - static { - kirinDecoderPrefixes = new LinkedList<>(); - - kirinDecoderPrefixes.add("omx.hisi"); - kirinDecoderPrefixes.add("c2.hisi"); // Unconfirmed - } - - static { - exynosDecoderPrefixes = new LinkedList<>(); - - exynosDecoderPrefixes.add("omx.exynos"); - exynosDecoderPrefixes.add("c2.exynos"); - } - - static { - amlogicDecoderPrefixes = new LinkedList<>(); - - amlogicDecoderPrefixes.add("omx.amlogic"); - amlogicDecoderPrefixes.add("c2.amlogic"); // Unconfirmed - } - - private static boolean isPowerVR(String glRenderer) { - return glRenderer.toLowerCase().contains("powervr"); - } - - private static String getAdrenoVersionString(String glRenderer) { - glRenderer = glRenderer.toLowerCase().trim(); - - if (!glRenderer.contains("adreno")) { - return null; - } - - Pattern modelNumberPattern = Pattern.compile("(.*)([0-9]{3})(.*)"); - - Matcher matcher = modelNumberPattern.matcher(glRenderer); - if (!matcher.matches()) { - return null; - } - - String modelNumber = matcher.group(2); - LimeLog.info("Found Adreno GPU: "+modelNumber); - return modelNumber; - } - - private static boolean isLowEndSnapdragonRenderer(String glRenderer) { - String modelNumber = getAdrenoVersionString(glRenderer); - if (modelNumber == null) { - // Not an Adreno GPU - return false; - } - - // The current logic is to identify low-end SoCs based on a zero in the x0x place. - return modelNumber.charAt(1) == '0'; - } - - private static int getAdrenoRendererModelNumber(String glRenderer) { - String modelNumber = getAdrenoVersionString(glRenderer); - if (modelNumber == null) { - // Not an Adreno GPU - return -1; - } - - return Integer.parseInt(modelNumber); - } - - // This is a workaround for some broken devices that report - // only GLES 3.0 even though the GPU is an Adreno 4xx series part. - // An example of such a device is the Huawei Honor 5x with the - // Snapdragon 616 SoC (Adreno 405). - private static boolean isGLES31SnapdragonRenderer(String glRenderer) { - // Snapdragon 4xx and higher support GLES 3.1 - return getAdrenoRendererModelNumber(glRenderer) >= 400; - } - - public static void initialize(Context context, String glRenderer) { - if (initialized) { - return; - } - - // Older Sony ATVs (SVP-DTV15) have broken MediaTek codecs (decoder hangs after rendering the first frame). - // I know the Fire TV 2 and 3 works, so I'll whitelist Amazon devices which seem to actually be tested. - // We still have to check Build.MANUFACTURER to catch Amazon Fire tablets. - if (context.getPackageManager().hasSystemFeature("amazon.hardware.fire_tv") || - Build.MANUFACTURER.equalsIgnoreCase("Amazon")) { - // HEVC and RFI have been confirmed working on Fire TV 2, Fire TV Stick 2, Fire TV 4K Max, - // Fire HD 8 2020, and Fire HD 8 2022 models. - // - // This is probably a good enough sample to conclude that all MediaTek Fire OS devices - // are likely to be okay. - whitelistedHevcDecoders.add("omx.mtk"); - refFrameInvalidationHevcPrefixes.add("omx.mtk"); - refFrameInvalidationHevcPrefixes.add("c2.mtk"); - - // This requires setting vdec-lowlatency on the Fire TV 3, otherwise the decoder - // never produces any output frames. See comment above for details on why we only - // do this for Fire TV devices. - whitelistedHevcDecoders.add("omx.amlogic"); - - // Fire TV 3 seems to produce random artifacts on HEVC streams after packet loss. - // Enabling RFI turns these artifacts into full decoder output hangs, so let's not enable - // that for Fire OS 6 Amlogic devices. We will leave HEVC enabled because that's the only - // way these devices can hit 4K. Hopefully this is just a problem with the BSP used in - // the Fire OS 6 Amlogic devices, so we will leave this enabled for Fire OS 7+. - // - // Apart from a few TV models, the main Amlogic-based Fire TV devices are the Fire TV - // Cubes and Fire TV 3. This check will exclude the Fire TV 3 and Fire TV Cube 1, but - // allow the newer Fire TV Cubes to use HEVC RFI. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - refFrameInvalidationHevcPrefixes.add("omx.amlogic"); - refFrameInvalidationHevcPrefixes.add("c2.amlogic"); - } - } - - ActivityManager activityManager = - (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); - ConfigurationInfo configInfo = activityManager.getDeviceConfigurationInfo(); - if (configInfo.reqGlEsVersion != ConfigurationInfo.GL_ES_VERSION_UNDEFINED) { - LimeLog.info("OpenGL ES version: "+configInfo.reqGlEsVersion); - - isLowEndSnapdragon = isLowEndSnapdragonRenderer(glRenderer); - isAdreno620 = getAdrenoRendererModelNumber(glRenderer) == 620; - - // Tegra K1 and later can do reference frame invalidation properly - if (configInfo.reqGlEsVersion >= 0x30000) { - LimeLog.info("Added omx.nvidia/c2.nvidia to reference frame invalidation support list"); - refFrameInvalidationAvcPrefixes.add("omx.nvidia"); - - // Exclude HEVC RFI on Pixel C and Tegra devices prior to Android 11. Misbehaving RFI - // on these devices can cause hundreds of milliseconds of latency, so it's not worth - // using it unless we're absolutely sure that it will not cause increased latency. - if (!Build.DEVICE.equalsIgnoreCase("dragon") && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - refFrameInvalidationHevcPrefixes.add("omx.nvidia"); - } - - refFrameInvalidationAvcPrefixes.add("c2.nvidia"); // Unconfirmed - refFrameInvalidationHevcPrefixes.add("c2.nvidia"); // Unconfirmed - - LimeLog.info("Added omx.qcom/c2.qti to reference frame invalidation support list"); - refFrameInvalidationAvcPrefixes.add("omx.qcom"); - refFrameInvalidationHevcPrefixes.add("omx.qcom"); - refFrameInvalidationAvcPrefixes.add("c2.qti"); - refFrameInvalidationHevcPrefixes.add("c2.qti"); - } - - // Qualcomm's early HEVC decoders break hard on our HEVC stream. The best check to - // tell the good from the bad decoders are the generation of Adreno GPU included: - // 3xx - bad - // 4xx - good - // - // The "good" GPUs support GLES 3.1, but we can't just check that directly - // (see comment on isGLES31SnapdragonRenderer). - // - if (isGLES31SnapdragonRenderer(glRenderer)) { - LimeLog.info("Added omx.qcom/c2.qti to HEVC decoders based on GLES 3.1+ support"); - whitelistedHevcDecoders.add("omx.qcom"); - whitelistedHevcDecoders.add("c2.qti"); - } - else { - blacklistedDecoderPrefixes.add("OMX.qcom.video.decoder.hevc"); - - // These older decoders need 4 slices per frame for best performance - useFourSlicesPrefixes.add("omx.qcom"); - } - - // Older MediaTek SoCs have issues with HEVC rendering but the newer chips with - // PowerVR GPUs have good HEVC support. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isPowerVR(glRenderer)) { - LimeLog.info("Added omx.mtk to HEVC decoders based on PowerVR GPU"); - whitelistedHevcDecoders.add("omx.mtk"); - - // This SoC (MT8176 in GPD XD+) supports AVC RFI too, but the maxNumReferenceFrames setting - // required to make it work adds a huge amount of latency. However, RFI on HEVC causes - // decoder hangs on the newer GE8100, GE8300, and GE8320 GPUs, so we limit it to the - // Series6XT GPUs where we know it works. - if (glRenderer.contains("GX6")) { - LimeLog.info("Added omx.mtk/c2.mtk to RFI list for HEVC"); - refFrameInvalidationHevcPrefixes.add("omx.mtk"); - refFrameInvalidationHevcPrefixes.add("c2.mtk"); - } - } - } - - initialized = true; - } - - private static boolean isDecoderInList(List decoderList, String decoderName) { - if (!initialized) { - throw new IllegalStateException("MediaCodecHelper must be initialized before use"); - } - - for (String badPrefix : decoderList) { - if (decoderName.length() >= badPrefix.length()) { - String prefix = decoderName.substring(0, badPrefix.length()); - if (prefix.equalsIgnoreCase(badPrefix)) { - return true; - } - } - } - - return false; - } - - private static boolean decoderSupportsAndroidRLowLatency(MediaCodecInfo decoderInfo, String mimeType) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - try { - if (decoderInfo.getCapabilitiesForType(mimeType).isFeatureSupported(CodecCapabilities.FEATURE_LowLatency)) { - LimeLog.info("Low latency decoding mode supported (FEATURE_LowLatency)"); - return true; - } - } catch (Exception e) { - // Tolerate buggy codecs - e.printStackTrace(); - } - } - - return false; - } - - private static boolean decoderSupportsKnownVendorLowLatencyOption(String decoderName) { - // It's only possible to probe vendor parameters on Android 12 and above. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - MediaCodec testCodec = null; - try { - // Unfortunately we have to create an actual codec instance to get supported options. - testCodec = MediaCodec.createByCodecName(decoderName); - - // See if any of the vendor parameters match ones we know about - for (String supportedOption : testCodec.getSupportedVendorParameters()) { - for (String knownLowLatencyOption : knownVendorLowLatencyOptions) { - if (supportedOption.equalsIgnoreCase(knownLowLatencyOption)) { - LimeLog.info(decoderName + " supports known low latency option: " + supportedOption); - return true; - } - } - } - } catch (Exception e) { - // Tolerate buggy codecs - e.printStackTrace(); - } finally { - if (testCodec != null) { - testCodec.release(); - } - } - } - return false; - } - - private static boolean decoderSupportsMaxOperatingRate(String decoderName) { - // Operate at maximum rate to lower latency as much as possible on - // some Qualcomm platforms. We could also set KEY_PRIORITY to 0 (realtime) - // but that will actually result in the decoder crashing if it can't satisfy - // our (ludicrous) operating rate requirement. This seems to cause reliable - // crashes on the Xiaomi Mi 10 lite 5G and Redmi K30i 5G on Android 10, so - // we'll disable it on Snapdragon 765G and all non-Qualcomm devices to be safe. - // - // NB: Even on Android 10, this optimization still provides significant - // performance gains on Pixel 2. - return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && - isDecoderInList(qualcommDecoderPrefixes, decoderName) && - !isAdreno620; - } - - public static boolean setDecoderLowLatencyOptions(MediaFormat videoFormat, MediaCodecInfo decoderInfo, int tryNumber) { - // Options here should be tried in the order of most to least risky. The decoder will use - // the first MediaFormat that doesn't fail in configure(). - - boolean setNewOption = false; - - if (tryNumber < 1) { - // Official Android 11+ low latency option (KEY_LOW_LATENCY). - videoFormat.setInteger("low-latency", 1); - setNewOption = true; - - // If this decoder officially supports FEATURE_LowLatency, we will just use that alone - // for try 0. Otherwise, we'll include it as best effort with other options. - if (decoderSupportsAndroidRLowLatency(decoderInfo, videoFormat.getString(MediaFormat.KEY_MIME))) { - return true; - } - } - - if (tryNumber < 2 && - (!Build.MANUFACTURER.equalsIgnoreCase("xiaomi") || Build.VERSION.SDK_INT > Build.VERSION_CODES.M)) { - // MediaTek decoders don't use vendor-defined keys for low latency mode. Instead, they have a modified - // version of AOSP's ACodec.cpp which supports the "vdec-lowlatency" option. This option is passed down - // to the decoder as OMX.MTK.index.param.video.LowLatencyDecode. - // - // This option is also plumbed for Amazon Amlogic-based devices like the Fire TV 3. Not only does it - // reduce latency on Amlogic, it fixes the HEVC bug that causes the decoder to not output any frames. - // Unfortunately, it does the exact opposite for the Xiaomi MITV4-ANSM0, breaking it in the way that - // Fire TV was broken prior to vdec-lowlatency :( - // - // On Fire TV 3, vdec-lowlatency is translated to OMX.amazon.fireos.index.video.lowLatencyDecode. - // - // https://github.com/yuan1617/Framwork/blob/master/frameworks/av/media/libstagefright/ACodec.cpp - // https://github.com/iykex/vendor_mediatek_proprietary_hardware/blob/master/libomx/video/MtkOmxVdecEx/MtkOmxVdecEx.h - videoFormat.setInteger("vdec-lowlatency", 1); - setNewOption = true; - } - - if (tryNumber < 3) { - if (MediaCodecHelper.decoderSupportsMaxOperatingRate(decoderInfo.getName())) { - videoFormat.setInteger(MediaFormat.KEY_OPERATING_RATE, Short.MAX_VALUE); - setNewOption = true; - } - else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - videoFormat.setInteger(MediaFormat.KEY_PRIORITY, 0); - setNewOption = true; - } - } - - // MediaCodec supports vendor-defined format keys using the "vendor.." syntax. - // These allow access to functionality that is not exposed through documented MediaFormat.KEY_* values. - // https://cs.android.com/android/platform/superproject/+/master:hardware/qcom/sdm845/media/mm-video-v4l2/vidc/common/inc/vidc_vendor_extensions.h;l=67 - // - // MediaCodec vendor extension support was introduced in Android 8.0: - // https://cs.android.com/android/_/android/platform/frameworks/av/+/01c10f8cdcd58d1e7025f426a72e6e75ba5d7fc2 - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - // Try vendor-specific low latency options - // - // NOTE: Update knownVendorLowLatencyOptions if you modify this code! - if (isDecoderInList(qualcommDecoderPrefixes, decoderInfo.getName())) { - // Examples of Qualcomm's vendor extensions for Snapdragon 845: - // https://cs.android.com/android/platform/superproject/+/master:hardware/qcom/sdm845/media/mm-video-v4l2/vidc/vdec/src/omx_vdec_extensions.hpp - // https://cs.android.com/android/_/android/platform/hardware/qcom/sm8150/media/+/0621ceb1c1b19564999db8293574a0e12952ff6c - // - // We will first try both, then try vendor.qti-ext-dec-low-latency.enable alone if that fails - if (tryNumber < 4) { - videoFormat.setInteger("vendor.qti-ext-dec-picture-order.enable", 1); - setNewOption = true; - } - if (tryNumber < 5) { - videoFormat.setInteger("vendor.qti-ext-dec-low-latency.enable", 1); - setNewOption = true; - } - } - else if (isDecoderInList(kirinDecoderPrefixes, decoderInfo.getName())) { - if (tryNumber < 4) { - // Kirin low latency options - // https://developer.huawei.com/consumer/cn/forum/topic/0202325564295980115 - videoFormat.setInteger("vendor.hisi-ext-low-latency-video-dec.video-scene-for-low-latency-req", 1); - videoFormat.setInteger("vendor.hisi-ext-low-latency-video-dec.video-scene-for-low-latency-rdy", -1); - setNewOption = true; - } - } - else if (isDecoderInList(exynosDecoderPrefixes, decoderInfo.getName())) { - if (tryNumber < 4) { - // Exynos low latency option for H.264 decoder - videoFormat.setInteger("vendor.rtc-ext-dec-low-latency.enable", 1); - setNewOption = true; - } - } - else if (isDecoderInList(amlogicDecoderPrefixes, decoderInfo.getName())) { - if (tryNumber < 4) { - // Amlogic low latency vendor extension - // https://github.com/codewalkerster/android_vendor_amlogic_common_prebuilt_libstagefrighthw/commit/41fefc4e035c476d58491324a5fe7666bfc2989e - videoFormat.setInteger("vendor.low-latency.enable", 1); - setNewOption = true; - } - } - } - - return setNewOption; - } - - public static boolean decoderSupportsFusedIdrFrame(MediaCodecInfo decoderInfo, String mimeType) { - // If adaptive playback is supported, we can submit new CSD together with a keyframe - try { - if (decoderInfo.getCapabilitiesForType(mimeType). - isFeatureSupported(CodecCapabilities.FEATURE_AdaptivePlayback)) { - LimeLog.info("Decoder supports fused IDR frames (FEATURE_AdaptivePlayback)"); - return true; - } - } catch (Exception e) { - // Tolerate buggy codecs - e.printStackTrace(); - } - - return false; - } - - public static boolean decoderSupportsAdaptivePlayback(MediaCodecInfo decoderInfo, String mimeType) { - if (isDecoderInList(blacklistedAdaptivePlaybackPrefixes, decoderInfo.getName())) { - LimeLog.info("Decoder blacklisted for adaptive playback"); - return false; - } - - try { - if (decoderInfo.getCapabilitiesForType(mimeType). - isFeatureSupported(CodecCapabilities.FEATURE_AdaptivePlayback)) - { - // This will make getCapabilities() return that adaptive playback is supported - LimeLog.info("Adaptive playback supported (FEATURE_AdaptivePlayback)"); - return true; - } - } catch (Exception e) { - // Tolerate buggy codecs - e.printStackTrace(); - } - - return false; - } - - public static boolean decoderNeedsConstrainedHighProfile(String decoderName) { - return isDecoderInList(constrainedHighProfilePrefixes, decoderName); - } - - public static boolean decoderCanDirectSubmit(String decoderName) { - return isDecoderInList(directSubmitPrefixes, decoderName) && !isExynos4Device(); - } - - public static boolean decoderNeedsSpsBitstreamRestrictions(String decoderName) { - return isDecoderInList(spsFixupBitstreamFixupDecoderPrefixes, decoderName); - } - - public static boolean decoderNeedsBaselineSpsHack(String decoderName) { - return isDecoderInList(baselineProfileHackPrefixes, decoderName); - } - - public static byte getDecoderOptimalSlicesPerFrame(String decoderName) { - if (isDecoderInList(useFourSlicesPrefixes, decoderName)) { - // 4 slices per frame reduces decoding latency on older Qualcomm devices - return 4; - } - else { - // 1 slice per frame produces the optimal encoding efficiency - return 1; - } - } - - public static boolean decoderSupportsRefFrameInvalidationAvc(String decoderName, int videoHeight) { - // Reference frame invalidation is broken on low-end Snapdragon SoCs at 1080p. - if (videoHeight > 720 && isLowEndSnapdragon) { - return false; - } - - // This device seems to crash constantly at 720p, so try disabling - // RFI to see if we can get that under control. - if (Build.DEVICE.equals("b3") || Build.DEVICE.equals("b5")) { - return false; - } - - return isDecoderInList(refFrameInvalidationAvcPrefixes, decoderName); - } - - public static boolean decoderSupportsRefFrameInvalidationHevc(MediaCodecInfo decoderInfo) { - // HEVC decoders seem to universally support RFI, but it can have huge latency penalties - // for some decoders due to the number of references frames being > 1. Old Amlogic - // decoders are known to have this problem. - // - // If the decoder supports FEATURE_LowLatency or any vendor low latency option, - // we will use that as an indication that it can handle HEVC RFI without excessively - // buffering frames. - if (decoderSupportsAndroidRLowLatency(decoderInfo, "video/hevc") || - decoderSupportsKnownVendorLowLatencyOption(decoderInfo.getName())) { - LimeLog.info("Enabling HEVC RFI based on low latency option support"); - return true; - } - - return isDecoderInList(refFrameInvalidationHevcPrefixes, decoderInfo.getName()); - } - - public static boolean decoderSupportsRefFrameInvalidationAv1(MediaCodecInfo decoderInfo) { - // We'll use the same heuristics as HEVC for now - if (decoderSupportsAndroidRLowLatency(decoderInfo, "video/av01") || - decoderSupportsKnownVendorLowLatencyOption(decoderInfo.getName())) { - LimeLog.info("Enabling AV1 RFI based on low latency option support"); - return true; - } - - return false; - } - - public static boolean decoderIsWhitelistedForHevc(MediaCodecInfo decoderInfo) { - // - // Software decoders are terrible and we never want to use them. - // We want to catch decoders like: - // OMX.qcom.video.decoder.hevcswvdec - // OMX.SEC.hevc.sw.dec - // - if (decoderInfo.getName().contains("sw")) { - LimeLog.info("Disallowing HEVC on software decoder: " + decoderInfo.getName()); - return false; - } - else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && (!decoderInfo.isHardwareAccelerated() || decoderInfo.isSoftwareOnly())) { - LimeLog.info("Disallowing HEVC on software decoder: " + decoderInfo.getName()); - return false; - } - - // If this device is media performance class 12 or higher, we will assume any hardware - // HEVC decoder present is fast and modern enough for streaming. - // - // [5.3/H-1-1] MUST NOT drop more than 2 frames in 10 seconds (i.e less than 0.333 percent frame drop) for a 1080p 60 fps video session under load. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - LimeLog.info("Media performance class: " + Build.VERSION.MEDIA_PERFORMANCE_CLASS); - if (Build.VERSION.MEDIA_PERFORMANCE_CLASS >= Build.VERSION_CODES.S) { - LimeLog.info("Allowing HEVC based on media performance class"); - return true; - } - } - - // If the decoder supports FEATURE_LowLatency, we will assume it is fast and modern enough - // to be preferable for streaming over H.264 decoders. - if (decoderSupportsAndroidRLowLatency(decoderInfo, "video/hevc")) { - LimeLog.info("Allowing HEVC based on FEATURE_LowLatency support"); - return true; - } - - // Otherwise, we use our list of known working HEVC decoders - return isDecoderInList(whitelistedHevcDecoders, decoderInfo.getName()); - } - - public static boolean isDecoderWhitelistedForAv1(MediaCodecInfo decoderInfo) { - // Google didn't have official support for AV1 (or more importantly, a CTS test) until - // Android 10, so don't use any decoder before then. - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - return false; - } - - // - // Software decoders are terrible and we never want to use them. - // We want to catch decoders like: - // OMX.qcom.video.decoder.hevcswvdec - // OMX.SEC.hevc.sw.dec - // - if (decoderInfo.getName().contains("sw")) { - LimeLog.info("Disallowing AV1 on software decoder: " + decoderInfo.getName()); - return false; - } - else if (!decoderInfo.isHardwareAccelerated() || decoderInfo.isSoftwareOnly()) { - LimeLog.info("Disallowing AV1 on software decoder: " + decoderInfo.getName()); - return false; - } - - // TODO: Test some AV1 decoders - return false; - } - - @SuppressWarnings("deprecation") - @SuppressLint("NewApi") - private static LinkedList getMediaCodecList() { - LinkedList infoList = new LinkedList<>(); - - MediaCodecList mcl = new MediaCodecList(MediaCodecList.REGULAR_CODECS); - Collections.addAll(infoList, mcl.getCodecInfos()); - - return infoList; - } - - @SuppressWarnings("RedundantThrows") - public static String dumpDecoders() throws Exception { - String str = ""; - for (MediaCodecInfo codecInfo : getMediaCodecList()) { - // Skip encoders - if (codecInfo.isEncoder()) { - continue; - } - - str += "Decoder: "+codecInfo.getName()+"\n"; - for (String type : codecInfo.getSupportedTypes()) { - str += "\t"+type+"\n"; - CodecCapabilities caps = codecInfo.getCapabilitiesForType(type); - - for (CodecProfileLevel profile : caps.profileLevels) { - str += "\t\t"+profile.profile+" "+profile.level+"\n"; - } - } - } - return str; - } - - private static MediaCodecInfo findPreferredDecoder() { - // This is a different algorithm than the other findXXXDecoder functions, - // because we want to evaluate the decoders in our list's order - // rather than MediaCodecList's order - - if (!initialized) { - throw new IllegalStateException("MediaCodecHelper must be initialized before use"); - } - - for (String preferredDecoder : preferredDecoders) { - for (MediaCodecInfo codecInfo : getMediaCodecList()) { - // Skip encoders - if (codecInfo.isEncoder()) { - continue; - } - - // Check for preferred decoders - if (preferredDecoder.equalsIgnoreCase(codecInfo.getName())) { - LimeLog.info("Preferred decoder choice is "+codecInfo.getName()); - return codecInfo; - } - } - } - - return null; - } - - private static boolean isCodecBlacklisted(MediaCodecInfo codecInfo) { - // Use the new isSoftwareOnly() function on Android Q - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - if (!SHOULD_BYPASS_SOFTWARE_BLOCK && codecInfo.isSoftwareOnly()) { - LimeLog.info("Skipping software-only decoder: "+codecInfo.getName()); - return true; - } - } - - // Check for explicitly blacklisted decoders - if (isDecoderInList(blacklistedDecoderPrefixes, codecInfo.getName())) { - LimeLog.info("Skipping blacklisted decoder: "+codecInfo.getName()); - return true; - } - - return false; - } - - public static MediaCodecInfo findFirstDecoder(String mimeType) { - for (MediaCodecInfo codecInfo : getMediaCodecList()) { - // Skip encoders - if (codecInfo.isEncoder()) { - continue; - } - - // Skip compatibility aliases on Q+ - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - if (codecInfo.isAlias()) { - continue; - } - } - - // Find a decoder that supports the specified video format - for (String mime : codecInfo.getSupportedTypes()) { - if (mime.equalsIgnoreCase(mimeType)) { - // Skip blacklisted codecs - if (isCodecBlacklisted(codecInfo)) { - continue; - } - - LimeLog.info("First decoder choice is "+codecInfo.getName()); - return codecInfo; - } - } - } - - return null; - } - - public static MediaCodecInfo findProbableSafeDecoder(String mimeType, int requiredProfile) { - // First look for a preferred decoder by name - MediaCodecInfo info = findPreferredDecoder(); - if (info != null) { - return info; - } - - // Now look for decoders we know are safe - try { - // If this function completes, it will determine if the decoder is safe - return findKnownSafeDecoder(mimeType, requiredProfile); - } catch (Exception e) { - // Some buggy devices seem to throw exceptions - // from getCapabilitiesForType() so we'll just assume - // they're okay and go with the first one we find - return findFirstDecoder(mimeType); - } - } - - // We declare this method as explicitly throwing Exception - // since some bad decoders can throw IllegalArgumentExceptions unexpectedly - // and we want to be sure all callers are handling this possibility - @SuppressWarnings("RedundantThrows") - private static MediaCodecInfo findKnownSafeDecoder(String mimeType, int requiredProfile) throws Exception { - // Some devices (Exynos devces, at least) have two sets of decoders. - // The first set of decoders are C2 which do not support FEATURE_LowLatency, - // but the second set of OMX decoders do support FEATURE_LowLatency. We want - // to pick the OMX decoders despite the fact that C2 is listed first. - // On some Qualcomm devices (like Pixel 4), there are separate low latency decoders - // (like c2.qti.hevc.decoder.low_latency) that advertise FEATURE_LowLatency while - // the standard ones (like c2.qti.hevc.decoder) do not. Like Exynos, the decoders - // with FEATURE_LowLatency support are listed after the standard ones. - for (int i = 0; i < 2; i++) { - for (MediaCodecInfo codecInfo : getMediaCodecList()) { - // Skip encoders - if (codecInfo.isEncoder()) { - continue; - } - - // Skip compatibility aliases on Q+ - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - if (codecInfo.isAlias()) { - continue; - } - } - - // Find a decoder that supports the requested video format - for (String mime : codecInfo.getSupportedTypes()) { - if (mime.equalsIgnoreCase(mimeType)) { - LimeLog.info("Examining decoder capabilities of " + codecInfo.getName() + " (round " + (i + 1) + ")"); - - // Skip blacklisted codecs - if (isCodecBlacklisted(codecInfo)) { - continue; - } - - CodecCapabilities caps = codecInfo.getCapabilitiesForType(mime); - - if (i == 0 && !decoderSupportsAndroidRLowLatency(codecInfo, mime)) { - LimeLog.info("Skipping decoder that lacks FEATURE_LowLatency for round 1"); - continue; - } - - if (requiredProfile != -1) { - for (CodecProfileLevel profile : caps.profileLevels) { - if (profile.profile == requiredProfile) { - LimeLog.info("Decoder " + codecInfo.getName() + " supports required profile"); - return codecInfo; - } - } - - LimeLog.info("Decoder " + codecInfo.getName() + " does NOT support required profile"); - } else { - return codecInfo; - } - } - } - } - } - - return null; - } - - public static String readCpuinfo() throws Exception { - StringBuilder cpuInfo = new StringBuilder(); - try (final BufferedReader br = new BufferedReader(new FileReader(new File("/proc/cpuinfo")))) { - for (;;) { - int ch = br.read(); - if (ch == -1) - break; - cpuInfo.append((char)ch); - } - - return cpuInfo.toString(); - } - } - - private static boolean stringContainsIgnoreCase(String string, String substring) { - return string.toLowerCase(Locale.ENGLISH).contains(substring.toLowerCase(Locale.ENGLISH)); - } - - public static boolean isExynos4Device() { - try { - // Try reading CPU info too look for - String cpuInfo = readCpuinfo(); - - // SMDK4xxx is Exynos 4 - if (stringContainsIgnoreCase(cpuInfo, "SMDK4")) { - LimeLog.info("Found SMDK4 in /proc/cpuinfo"); - return true; - } - - // If we see "Exynos 4" also we'll count it - if (stringContainsIgnoreCase(cpuInfo, "Exynos 4")) { - LimeLog.info("Found Exynos 4 in /proc/cpuinfo"); - return true; - } - } catch (Exception e) { - e.printStackTrace(); - } - - try { - File systemDir = new File("/sys/devices/system"); - File[] files = systemDir.listFiles(); - if (files != null) { - for (File f : files) { - if (stringContainsIgnoreCase(f.getName(), "exynos4")) { - LimeLog.info("Found exynos4 in /sys/devices/system"); - return true; - } - } - } - } catch (Exception e) { - e.printStackTrace(); - } - - return false; - } -} +package com.limelight.binding.video; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileReader; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import android.annotation.SuppressLint; +import android.app.ActivityManager; +import android.content.Context; +import android.content.pm.ConfigurationInfo; +import android.media.MediaCodec; +import android.media.MediaCodecInfo; +import android.media.MediaCodecList; +import android.media.MediaCodecInfo.CodecCapabilities; +import android.media.MediaCodecInfo.CodecProfileLevel; +import android.media.MediaFormat; +import android.os.Build; + +import com.limelight.LimeLog; +import com.limelight.preferences.PreferenceConfiguration; + +public class MediaCodecHelper { + + private static final List preferredDecoders; + + private static final List blacklistedDecoderPrefixes; + private static final List spsFixupBitstreamFixupDecoderPrefixes; + private static final List blacklistedAdaptivePlaybackPrefixes; + private static final List baselineProfileHackPrefixes; + private static final List directSubmitPrefixes; + private static final List constrainedHighProfilePrefixes; + private static final List whitelistedHevcDecoders; + private static final List refFrameInvalidationAvcPrefixes; + private static final List refFrameInvalidationHevcPrefixes; + private static final List useFourSlicesPrefixes; + private static final List qualcommDecoderPrefixes; + private static final List kirinDecoderPrefixes; + private static final List exynosDecoderPrefixes; + private static final List amlogicDecoderPrefixes; + private static final List knownVendorLowLatencyOptions; + + public static final boolean SHOULD_BYPASS_SOFTWARE_BLOCK = + Build.HARDWARE.equals("ranchu") || Build.HARDWARE.equals("cheets") || Build.BRAND.equals("Android-x86"); + + private static boolean isLowEndSnapdragon = false; + private static boolean isAdreno620 = false; + private static boolean initialized = false; + + static { + directSubmitPrefixes = new LinkedList<>(); + + // These decoders have low enough input buffer latency that they + // can be directly invoked from the receive thread + directSubmitPrefixes.add("omx.qcom"); + directSubmitPrefixes.add("omx.sec"); + directSubmitPrefixes.add("omx.exynos"); + directSubmitPrefixes.add("omx.intel"); + directSubmitPrefixes.add("omx.brcm"); + directSubmitPrefixes.add("omx.TI"); + directSubmitPrefixes.add("omx.arc"); + directSubmitPrefixes.add("omx.nvidia"); + + // All Codec2 decoders + directSubmitPrefixes.add("c2."); + } + + static { + refFrameInvalidationAvcPrefixes = new LinkedList<>(); + + refFrameInvalidationHevcPrefixes = new LinkedList<>(); + refFrameInvalidationHevcPrefixes.add("omx.exynos"); + refFrameInvalidationHevcPrefixes.add("c2.exynos"); + + // Qualcomm and NVIDIA may be added at runtime + } + + static { + preferredDecoders = new LinkedList<>(); + } + + static { + blacklistedDecoderPrefixes = new LinkedList<>(); + + // Blacklist software decoders that don't support H264 high profile except on systems + // that are expected to only have software decoders (like emulators). + if (!SHOULD_BYPASS_SOFTWARE_BLOCK) { + blacklistedDecoderPrefixes.add("omx.google"); + blacklistedDecoderPrefixes.add("AVCDecoder"); + + // We want to avoid ffmpeg decoders since they're usually software decoders, + // but we'll defer to the Android 10 isSoftwareOnly() API on newer devices + // to determine if we should use these or not. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + blacklistedDecoderPrefixes.add("OMX.ffmpeg"); + } + } + + // Force these decoders disabled because: + // 1) They are software decoders, so the performance is terrible + // 2) They crash with our HEVC stream anyway (at least prior to CSD batching) + blacklistedDecoderPrefixes.add("OMX.qcom.video.decoder.hevcswvdec"); + blacklistedDecoderPrefixes.add("OMX.SEC.hevc.sw.dec"); + } + + static { + // If a decoder qualifies for reference frame invalidation, + // these entries will be ignored for those decoders. + spsFixupBitstreamFixupDecoderPrefixes = new LinkedList<>(); + spsFixupBitstreamFixupDecoderPrefixes.add("omx.nvidia"); + spsFixupBitstreamFixupDecoderPrefixes.add("omx.qcom"); + spsFixupBitstreamFixupDecoderPrefixes.add("omx.brcm"); + + baselineProfileHackPrefixes = new LinkedList<>(); + baselineProfileHackPrefixes.add("omx.intel"); + + blacklistedAdaptivePlaybackPrefixes = new LinkedList<>(); + // The Intel decoder on Lollipop on Nexus Player would increase latency badly + // if adaptive playback was enabled so let's avoid it to be safe. + blacklistedAdaptivePlaybackPrefixes.add("omx.intel"); + // The MediaTek decoder crashes at 1080p when adaptive playback is enabled + // on some Android TV devices with HEVC only. + blacklistedAdaptivePlaybackPrefixes.add("omx.mtk"); + + constrainedHighProfilePrefixes = new LinkedList<>(); + constrainedHighProfilePrefixes.add("omx.intel"); + } + + static { + whitelistedHevcDecoders = new LinkedList<>(); + + // Allow software HEVC decoding in the official AOSP emulator + if (Build.HARDWARE.equals("ranchu")) { + whitelistedHevcDecoders.add("omx.google"); + } + + // Exynos seems to be the only HEVC decoder that works reliably + whitelistedHevcDecoders.add("omx.exynos"); + + // On Darcy (Shield 2017), HEVC runs fine with no fixups required. For some reason, + // other X1 implementations require bitstream fixups. However, since numReferenceFrames + // has been supported in GFE since late 2017, we'll go ahead and enable HEVC for all + // device models. + // + // NVIDIA does partial HEVC acceleration on the Shield Tablet. I don't know + // whether the performance is good enough to use for streaming, but they're + // using the same omx.nvidia.h265.decode name as the Shield TV which has a + // fully accelerated HEVC pipeline. AFAIK, the only K1 devices with this + // partially accelerated HEVC decoder are the Shield Tablet and Xiaomi MiPad, + // so I'll check for those here. + // + // In case there are some that I missed, I will also exclude pre-Oreo OSes since + // only Shield ATV got an Oreo update and any newer Tegra devices will not ship + // with an old OS like Nougat. + if (!Build.DEVICE.equalsIgnoreCase("shieldtablet") && + !Build.DEVICE.equalsIgnoreCase("mocha") && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + whitelistedHevcDecoders.add("omx.nvidia"); + } + + // Plot twist: On newer Sony devices (BRAVIA_ATV2, BRAVIA_ATV3_4K, BRAVIA_UR1_4K) the H.264 decoder crashes + // on several configurations (> 60 FPS and 1440p) that work with HEVC, so we'll whitelist those devices for HEVC. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && Build.DEVICE.startsWith("BRAVIA_")) { + whitelistedHevcDecoders.add("omx.mtk"); + } + + // Amlogic requires 1 reference frame for HEVC to avoid hanging. Since it's been years + // since GFE added support for maxNumReferenceFrames, we'll just enable all Amlogic SoCs + // running Android 9 or later. + // + // NB: We don't do this on Sabrina (GCWGTV) because H.264 is lower latency when we use + // vendor.low-latency.enable. We will still use HEVC if decoderCanMeetPerformancePointWithHevcAndNotAvc() + // determines it's the only way to meet the performance requirements. + // + // With the Android 12 update, Sabrina now uses HEVC (with RFI) based upon FEATURE_LowLatency + // support, which provides equivalent latency to H.264 now. + // + // FIXME: Should we do this for all Amlogic S905X SoCs? + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && !Build.DEVICE.equalsIgnoreCase("sabrina")) { + whitelistedHevcDecoders.add("omx.amlogic"); + } + + // Realtek SoCs are used inside many Android TV devices and can only do 4K60 with HEVC. + // We'll enable those HEVC decoders by default and see if anything breaks. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + whitelistedHevcDecoders.add("omx.realtek"); + } + + // These theoretically have good HEVC decoding capabilities (potentially better than + // their AVC decoders), but haven't been tested enough + //whitelistedHevcDecoders.add("omx.rk"); + + // Let's see if HEVC decoders are finally stable with C2 + whitelistedHevcDecoders.add("c2."); + + // Based on GPU attributes queried at runtime, the omx.qcom/c2.qti prefix will be added + // during initialization to avoid SoCs with broken HEVC decoders. + } + + static { + useFourSlicesPrefixes = new LinkedList<>(); + + // Software decoders will use 4 slices per frame to allow for slice multithreading + useFourSlicesPrefixes.add("omx.google"); + useFourSlicesPrefixes.add("AVCDecoder"); + useFourSlicesPrefixes.add("omx.ffmpeg"); + useFourSlicesPrefixes.add("c2.android"); + + // Old Qualcomm decoders are detected at runtime + } + + static { + knownVendorLowLatencyOptions = new LinkedList<>(); + + knownVendorLowLatencyOptions.add("vendor.qti-ext-dec-low-latency.enable"); + knownVendorLowLatencyOptions.add("vendor.hisi-ext-low-latency-video-dec.video-scene-for-low-latency-req"); + knownVendorLowLatencyOptions.add("vendor.rtc-ext-dec-low-latency.enable"); + knownVendorLowLatencyOptions.add("vendor.low-latency.enable"); + } + + static { + qualcommDecoderPrefixes = new LinkedList<>(); + + qualcommDecoderPrefixes.add("omx.qcom"); + qualcommDecoderPrefixes.add("c2.qti"); + } + + static { + kirinDecoderPrefixes = new LinkedList<>(); + + kirinDecoderPrefixes.add("omx.hisi"); + kirinDecoderPrefixes.add("c2.hisi"); // Unconfirmed + } + + static { + exynosDecoderPrefixes = new LinkedList<>(); + + exynosDecoderPrefixes.add("omx.exynos"); + exynosDecoderPrefixes.add("c2.exynos"); + } + + static { + amlogicDecoderPrefixes = new LinkedList<>(); + + amlogicDecoderPrefixes.add("omx.amlogic"); + amlogicDecoderPrefixes.add("c2.amlogic"); // Unconfirmed + } + + private static boolean isPowerVR(String glRenderer) { + return glRenderer.toLowerCase().contains("powervr"); + } + + private static String getAdrenoVersionString(String glRenderer) { + glRenderer = glRenderer.toLowerCase().trim(); + + if (!glRenderer.contains("adreno")) { + return null; + } + + Pattern modelNumberPattern = Pattern.compile("(.*)([0-9]{3})(.*)"); + + Matcher matcher = modelNumberPattern.matcher(glRenderer); + if (!matcher.matches()) { + return null; + } + + String modelNumber = matcher.group(2); + LimeLog.info("Found Adreno GPU: "+modelNumber); + return modelNumber; + } + + private static boolean isLowEndSnapdragonRenderer(String glRenderer) { + String modelNumber = getAdrenoVersionString(glRenderer); + if (modelNumber == null) { + // Not an Adreno GPU + return false; + } + + // The current logic is to identify low-end SoCs based on a zero in the x0x place. + return modelNumber.charAt(1) == '0'; + } + + private static int getAdrenoRendererModelNumber(String glRenderer) { + String modelNumber = getAdrenoVersionString(glRenderer); + if (modelNumber == null) { + // Not an Adreno GPU + return -1; + } + + return Integer.parseInt(modelNumber); + } + + // This is a workaround for some broken devices that report + // only GLES 3.0 even though the GPU is an Adreno 4xx series part. + // An example of such a device is the Huawei Honor 5x with the + // Snapdragon 616 SoC (Adreno 405). + private static boolean isGLES31SnapdragonRenderer(String glRenderer) { + // Snapdragon 4xx and higher support GLES 3.1 + return getAdrenoRendererModelNumber(glRenderer) >= 400; + } + + public static void initialize(Context context, String glRenderer) { + if (initialized) { + return; + } + + // Older Sony ATVs (SVP-DTV15) have broken MediaTek codecs (decoder hangs after rendering the first frame). + // I know the Fire TV 2 and 3 works, so I'll whitelist Amazon devices which seem to actually be tested. + // We still have to check Build.MANUFACTURER to catch Amazon Fire tablets. + if (context.getPackageManager().hasSystemFeature("amazon.hardware.fire_tv") || + Build.MANUFACTURER.equalsIgnoreCase("Amazon")) { + // HEVC and RFI have been confirmed working on Fire TV 2, Fire TV Stick 2, Fire TV 4K Max, + // Fire HD 8 2020, and Fire HD 8 2022 models. + // + // This is probably a good enough sample to conclude that all MediaTek Fire OS devices + // are likely to be okay. + whitelistedHevcDecoders.add("omx.mtk"); + refFrameInvalidationHevcPrefixes.add("omx.mtk"); + refFrameInvalidationHevcPrefixes.add("c2.mtk"); + + // This requires setting vdec-lowlatency on the Fire TV 3, otherwise the decoder + // never produces any output frames. See comment above for details on why we only + // do this for Fire TV devices. + whitelistedHevcDecoders.add("omx.amlogic"); + + // Fire TV 3 seems to produce random artifacts on HEVC streams after packet loss. + // Enabling RFI turns these artifacts into full decoder output hangs, so let's not enable + // that for Fire OS 6 Amlogic devices. We will leave HEVC enabled because that's the only + // way these devices can hit 4K. Hopefully this is just a problem with the BSP used in + // the Fire OS 6 Amlogic devices, so we will leave this enabled for Fire OS 7+. + // + // Apart from a few TV models, the main Amlogic-based Fire TV devices are the Fire TV + // Cubes and Fire TV 3. This check will exclude the Fire TV 3 and Fire TV Cube 1, but + // allow the newer Fire TV Cubes to use HEVC RFI. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + refFrameInvalidationHevcPrefixes.add("omx.amlogic"); + refFrameInvalidationHevcPrefixes.add("c2.amlogic"); + } + } + + ActivityManager activityManager = + (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + ConfigurationInfo configInfo = activityManager.getDeviceConfigurationInfo(); + if (configInfo.reqGlEsVersion != ConfigurationInfo.GL_ES_VERSION_UNDEFINED) { + LimeLog.info("OpenGL ES version: "+configInfo.reqGlEsVersion); + + isLowEndSnapdragon = isLowEndSnapdragonRenderer(glRenderer); + isAdreno620 = getAdrenoRendererModelNumber(glRenderer) == 620; + + // Tegra K1 and later can do reference frame invalidation properly + if (configInfo.reqGlEsVersion >= 0x30000) { + LimeLog.info("Added omx.nvidia/c2.nvidia to reference frame invalidation support list"); + refFrameInvalidationAvcPrefixes.add("omx.nvidia"); + + // Exclude HEVC RFI on Pixel C and Tegra devices prior to Android 11. Misbehaving RFI + // on these devices can cause hundreds of milliseconds of latency, so it's not worth + // using it unless we're absolutely sure that it will not cause increased latency. + if (!Build.DEVICE.equalsIgnoreCase("dragon") && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + refFrameInvalidationHevcPrefixes.add("omx.nvidia"); + } + + refFrameInvalidationAvcPrefixes.add("c2.nvidia"); // Unconfirmed + refFrameInvalidationHevcPrefixes.add("c2.nvidia"); // Unconfirmed + + LimeLog.info("Added omx.qcom/c2.qti to reference frame invalidation support list"); + refFrameInvalidationAvcPrefixes.add("omx.qcom"); + refFrameInvalidationHevcPrefixes.add("omx.qcom"); + refFrameInvalidationAvcPrefixes.add("c2.qti"); + refFrameInvalidationHevcPrefixes.add("c2.qti"); + } + + // Qualcomm's early HEVC decoders break hard on our HEVC stream. The best check to + // tell the good from the bad decoders are the generation of Adreno GPU included: + // 3xx - bad + // 4xx - good + // + // The "good" GPUs support GLES 3.1, but we can't just check that directly + // (see comment on isGLES31SnapdragonRenderer). + // + if (isGLES31SnapdragonRenderer(glRenderer)) { + LimeLog.info("Added omx.qcom/c2.qti to HEVC decoders based on GLES 3.1+ support"); + whitelistedHevcDecoders.add("omx.qcom"); + whitelistedHevcDecoders.add("c2.qti"); + } + else { + blacklistedDecoderPrefixes.add("OMX.qcom.video.decoder.hevc"); + + // These older decoders need 4 slices per frame for best performance + useFourSlicesPrefixes.add("omx.qcom"); + } + + // Older MediaTek SoCs have issues with HEVC rendering but the newer chips with + // PowerVR GPUs have good HEVC support. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isPowerVR(glRenderer)) { + LimeLog.info("Added omx.mtk to HEVC decoders based on PowerVR GPU"); + whitelistedHevcDecoders.add("omx.mtk"); + + // This SoC (MT8176 in GPD XD+) supports AVC RFI too, but the maxNumReferenceFrames setting + // required to make it work adds a huge amount of latency. However, RFI on HEVC causes + // decoder hangs on the newer GE8100, GE8300, and GE8320 GPUs, so we limit it to the + // Series6XT GPUs where we know it works. + if (glRenderer.contains("GX6")) { + LimeLog.info("Added omx.mtk/c2.mtk to RFI list for HEVC"); + refFrameInvalidationHevcPrefixes.add("omx.mtk"); + refFrameInvalidationHevcPrefixes.add("c2.mtk"); + } + } + } + + initialized = true; + } + + private static boolean isDecoderInList(List decoderList, String decoderName) { + if (!initialized) { + throw new IllegalStateException("MediaCodecHelper must be initialized before use"); + } + + for (String badPrefix : decoderList) { + if (decoderName.length() >= badPrefix.length()) { + String prefix = decoderName.substring(0, badPrefix.length()); + if (prefix.equalsIgnoreCase(badPrefix)) { + return true; + } + } + } + + return false; + } + + private static boolean decoderSupportsAndroidRLowLatency(MediaCodecInfo decoderInfo, String mimeType) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + try { + if (decoderInfo.getCapabilitiesForType(mimeType).isFeatureSupported(CodecCapabilities.FEATURE_LowLatency)) { + LimeLog.info("Low latency decoding mode supported (FEATURE_LowLatency)"); + return true; + } + } catch (Exception e) { + // Tolerate buggy codecs + e.printStackTrace(); + } + } + + return false; + } + + private static boolean decoderSupportsKnownVendorLowLatencyOption(String decoderName) { + // It's only possible to probe vendor parameters on Android 12 and above. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + MediaCodec testCodec = null; + try { + // Unfortunately we have to create an actual codec instance to get supported options. + testCodec = MediaCodec.createByCodecName(decoderName); + + // See if any of the vendor parameters match ones we know about + for (String supportedOption : testCodec.getSupportedVendorParameters()) { + for (String knownLowLatencyOption : knownVendorLowLatencyOptions) { + if (supportedOption.equalsIgnoreCase(knownLowLatencyOption)) { + LimeLog.info(decoderName + " supports known low latency option: " + supportedOption); + return true; + } + } + } + } catch (Exception e) { + // Tolerate buggy codecs + e.printStackTrace(); + } finally { + if (testCodec != null) { + testCodec.release(); + } + } + } + return false; + } + + private static boolean decoderSupportsMaxOperatingRate(String decoderName) { + // Operate at maximum rate to lower latency as much as possible on + // some Qualcomm platforms. We could also set KEY_PRIORITY to 0 (realtime) + // but that will actually result in the decoder crashing if it can't satisfy + // our (ludicrous) operating rate requirement. This seems to cause reliable + // crashes on the Xiaomi Mi 10 lite 5G and Redmi K30i 5G on Android 10, so + // we'll disable it on Snapdragon 765G and all non-Qualcomm devices to be safe. + // + // NB: Even on Android 10, this optimization still provides significant + // performance gains on Pixel 2. + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && + isDecoderInList(qualcommDecoderPrefixes, decoderName) && + !isAdreno620; + } + + public static boolean setDecoderLowLatencyOptions(MediaFormat videoFormat, MediaCodecInfo decoderInfo, int tryNumber) { + // Options here should be tried in the order of most to least risky. The decoder will use + // the first MediaFormat that doesn't fail in configure(). + + boolean setNewOption = false; + + if (tryNumber < 1) { + // Official Android 11+ low latency option (KEY_LOW_LATENCY). + videoFormat.setInteger("low-latency", 1); + setNewOption = true; + + // If this decoder officially supports FEATURE_LowLatency, we will just use that alone + // for try 0. Otherwise, we'll include it as best effort with other options. + if (decoderSupportsAndroidRLowLatency(decoderInfo, videoFormat.getString(MediaFormat.KEY_MIME))) { + return true; + } + } + + if (tryNumber < 2 && + (!Build.MANUFACTURER.equalsIgnoreCase("xiaomi") || Build.VERSION.SDK_INT > Build.VERSION_CODES.M)) { + // MediaTek decoders don't use vendor-defined keys for low latency mode. Instead, they have a modified + // version of AOSP's ACodec.cpp which supports the "vdec-lowlatency" option. This option is passed down + // to the decoder as OMX.MTK.index.param.video.LowLatencyDecode. + // + // This option is also plumbed for Amazon Amlogic-based devices like the Fire TV 3. Not only does it + // reduce latency on Amlogic, it fixes the HEVC bug that causes the decoder to not output any frames. + // Unfortunately, it does the exact opposite for the Xiaomi MITV4-ANSM0, breaking it in the way that + // Fire TV was broken prior to vdec-lowlatency :( + // + // On Fire TV 3, vdec-lowlatency is translated to OMX.amazon.fireos.index.video.lowLatencyDecode. + // + // https://github.com/yuan1617/Framwork/blob/master/frameworks/av/media/libstagefright/ACodec.cpp + // https://github.com/iykex/vendor_mediatek_proprietary_hardware/blob/master/libomx/video/MtkOmxVdecEx/MtkOmxVdecEx.h + videoFormat.setInteger("vdec-lowlatency", 1); + setNewOption = true; + } + + if (tryNumber < 3) { + if (MediaCodecHelper.decoderSupportsMaxOperatingRate(decoderInfo.getName())) { + videoFormat.setInteger(MediaFormat.KEY_OPERATING_RATE, Short.MAX_VALUE); + setNewOption = true; + } + else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + videoFormat.setInteger(MediaFormat.KEY_PRIORITY, 0); + setNewOption = true; + } + } + + // MediaCodec supports vendor-defined format keys using the "vendor.." syntax. + // These allow access to functionality that is not exposed through documented MediaFormat.KEY_* values. + // https://cs.android.com/android/platform/superproject/+/master:hardware/qcom/sdm845/media/mm-video-v4l2/vidc/common/inc/vidc_vendor_extensions.h;l=67 + // + // MediaCodec vendor extension support was introduced in Android 8.0: + // https://cs.android.com/android/_/android/platform/frameworks/av/+/01c10f8cdcd58d1e7025f426a72e6e75ba5d7fc2 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // Try vendor-specific low latency options + // + // NOTE: Update knownVendorLowLatencyOptions if you modify this code! + if (isDecoderInList(qualcommDecoderPrefixes, decoderInfo.getName())) { + // Examples of Qualcomm's vendor extensions for Snapdragon 845: + // https://cs.android.com/android/platform/superproject/+/master:hardware/qcom/sdm845/media/mm-video-v4l2/vidc/vdec/src/omx_vdec_extensions.hpp + // https://cs.android.com/android/_/android/platform/hardware/qcom/sm8150/media/+/0621ceb1c1b19564999db8293574a0e12952ff6c + // + // We will first try both, then try vendor.qti-ext-dec-low-latency.enable alone if that fails + if (tryNumber < 4) { + videoFormat.setInteger("vendor.qti-ext-dec-picture-order.enable", 1); + setNewOption = true; + } + if (tryNumber < 5) { + videoFormat.setInteger("vendor.qti-ext-dec-low-latency.enable", 1); + setNewOption = true; + } + } + else if (isDecoderInList(kirinDecoderPrefixes, decoderInfo.getName())) { + if (tryNumber < 4) { + // Kirin low latency options + // https://developer.huawei.com/consumer/cn/forum/topic/0202325564295980115 + videoFormat.setInteger("vendor.hisi-ext-low-latency-video-dec.video-scene-for-low-latency-req", 1); + videoFormat.setInteger("vendor.hisi-ext-low-latency-video-dec.video-scene-for-low-latency-rdy", -1); + setNewOption = true; + } + } + else if (isDecoderInList(exynosDecoderPrefixes, decoderInfo.getName())) { + if (tryNumber < 4) { + // Exynos low latency option for H.264 decoder + videoFormat.setInteger("vendor.rtc-ext-dec-low-latency.enable", 1); + setNewOption = true; + } + } + else if (isDecoderInList(amlogicDecoderPrefixes, decoderInfo.getName())) { + if (tryNumber < 4) { + // Amlogic low latency vendor extension + // https://github.com/codewalkerster/android_vendor_amlogic_common_prebuilt_libstagefrighthw/commit/41fefc4e035c476d58491324a5fe7666bfc2989e + videoFormat.setInteger("vendor.low-latency.enable", 1); + setNewOption = true; + } + } + } + + return setNewOption; + } + + public static boolean decoderSupportsFusedIdrFrame(MediaCodecInfo decoderInfo, String mimeType) { + // If adaptive playback is supported, we can submit new CSD together with a keyframe + try { + if (decoderInfo.getCapabilitiesForType(mimeType). + isFeatureSupported(CodecCapabilities.FEATURE_AdaptivePlayback)) { + LimeLog.info("Decoder supports fused IDR frames (FEATURE_AdaptivePlayback)"); + return true; + } + } catch (Exception e) { + // Tolerate buggy codecs + e.printStackTrace(); + } + + return false; + } + + public static boolean decoderSupportsAdaptivePlayback(MediaCodecInfo decoderInfo, String mimeType) { + if (isDecoderInList(blacklistedAdaptivePlaybackPrefixes, decoderInfo.getName())) { + LimeLog.info("Decoder blacklisted for adaptive playback"); + return false; + } + + try { + if (decoderInfo.getCapabilitiesForType(mimeType). + isFeatureSupported(CodecCapabilities.FEATURE_AdaptivePlayback)) + { + // This will make getCapabilities() return that adaptive playback is supported + LimeLog.info("Adaptive playback supported (FEATURE_AdaptivePlayback)"); + return true; + } + } catch (Exception e) { + // Tolerate buggy codecs + e.printStackTrace(); + } + + return false; + } + + public static boolean decoderNeedsConstrainedHighProfile(String decoderName) { + return isDecoderInList(constrainedHighProfilePrefixes, decoderName); + } + + public static boolean decoderCanDirectSubmit(String decoderName) { + return isDecoderInList(directSubmitPrefixes, decoderName) && !isExynos4Device(); + } + + public static boolean decoderNeedsSpsBitstreamRestrictions(String decoderName) { + return isDecoderInList(spsFixupBitstreamFixupDecoderPrefixes, decoderName); + } + + public static boolean decoderNeedsBaselineSpsHack(String decoderName) { + return isDecoderInList(baselineProfileHackPrefixes, decoderName); + } + + public static byte getDecoderOptimalSlicesPerFrame(String decoderName) { + if (isDecoderInList(useFourSlicesPrefixes, decoderName)) { + // 4 slices per frame reduces decoding latency on older Qualcomm devices + return 4; + } + else { + // 1 slice per frame produces the optimal encoding efficiency + return 1; + } + } + + public static boolean decoderSupportsRefFrameInvalidationAvc(String decoderName, int videoHeight) { + // Reference frame invalidation is broken on low-end Snapdragon SoCs at 1080p. + if (videoHeight > 720 && isLowEndSnapdragon) { + return false; + } + + // This device seems to crash constantly at 720p, so try disabling + // RFI to see if we can get that under control. + if (Build.DEVICE.equals("b3") || Build.DEVICE.equals("b5")) { + return false; + } + + return isDecoderInList(refFrameInvalidationAvcPrefixes, decoderName); + } + + public static boolean decoderSupportsRefFrameInvalidationHevc(MediaCodecInfo decoderInfo) { + // HEVC decoders seem to universally support RFI, but it can have huge latency penalties + // for some decoders due to the number of references frames being > 1. Old Amlogic + // decoders are known to have this problem. + // + // If the decoder supports FEATURE_LowLatency or any vendor low latency option, + // we will use that as an indication that it can handle HEVC RFI without excessively + // buffering frames. + if (decoderSupportsAndroidRLowLatency(decoderInfo, "video/hevc") || + decoderSupportsKnownVendorLowLatencyOption(decoderInfo.getName())) { + LimeLog.info("Enabling HEVC RFI based on low latency option support"); + return true; + } + + return isDecoderInList(refFrameInvalidationHevcPrefixes, decoderInfo.getName()); + } + + public static boolean decoderSupportsRefFrameInvalidationAv1(MediaCodecInfo decoderInfo) { + // We'll use the same heuristics as HEVC for now + if (decoderSupportsAndroidRLowLatency(decoderInfo, "video/av01") || + decoderSupportsKnownVendorLowLatencyOption(decoderInfo.getName())) { + LimeLog.info("Enabling AV1 RFI based on low latency option support"); + return true; + } + + return false; + } + + public static boolean decoderIsWhitelistedForHevc(MediaCodecInfo decoderInfo) { + // + // Software decoders are terrible and we never want to use them. + // We want to catch decoders like: + // OMX.qcom.video.decoder.hevcswvdec + // OMX.SEC.hevc.sw.dec + // + if (decoderInfo.getName().contains("sw")) { + LimeLog.info("Disallowing HEVC on software decoder: " + decoderInfo.getName()); + return false; + } + else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && (!decoderInfo.isHardwareAccelerated() || decoderInfo.isSoftwareOnly())) { + LimeLog.info("Disallowing HEVC on software decoder: " + decoderInfo.getName()); + return false; + } + + // If this device is media performance class 12 or higher, we will assume any hardware + // HEVC decoder present is fast and modern enough for streaming. + // + // [5.3/H-1-1] MUST NOT drop more than 2 frames in 10 seconds (i.e less than 0.333 percent frame drop) for a 1080p 60 fps video session under load. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + LimeLog.info("Media performance class: " + Build.VERSION.MEDIA_PERFORMANCE_CLASS); + if (Build.VERSION.MEDIA_PERFORMANCE_CLASS >= Build.VERSION_CODES.S) { + LimeLog.info("Allowing HEVC based on media performance class"); + return true; + } + } + + // If the decoder supports FEATURE_LowLatency, we will assume it is fast and modern enough + // to be preferable for streaming over H.264 decoders. + if (decoderSupportsAndroidRLowLatency(decoderInfo, "video/hevc")) { + LimeLog.info("Allowing HEVC based on FEATURE_LowLatency support"); + return true; + } + + // Otherwise, we use our list of known working HEVC decoders + return isDecoderInList(whitelistedHevcDecoders, decoderInfo.getName()); + } + + public static boolean isDecoderWhitelistedForAv1(MediaCodecInfo decoderInfo) { + // Google didn't have official support for AV1 (or more importantly, a CTS test) until + // Android 10, so don't use any decoder before then. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + return false; + } + + // + // Software decoders are terrible and we never want to use them. + // We want to catch decoders like: + // OMX.qcom.video.decoder.hevcswvdec + // OMX.SEC.hevc.sw.dec + // + if (decoderInfo.getName().contains("sw")) { + LimeLog.info("Disallowing AV1 on software decoder: " + decoderInfo.getName()); + return false; + } + else if (!decoderInfo.isHardwareAccelerated() || decoderInfo.isSoftwareOnly()) { + LimeLog.info("Disallowing AV1 on software decoder: " + decoderInfo.getName()); + return false; + } + + // TODO: Test some AV1 decoders + return false; + } + + @SuppressWarnings("deprecation") + @SuppressLint("NewApi") + private static LinkedList getMediaCodecList() { + LinkedList infoList = new LinkedList<>(); + + MediaCodecList mcl = new MediaCodecList(MediaCodecList.REGULAR_CODECS); + Collections.addAll(infoList, mcl.getCodecInfos()); + + return infoList; + } + + @SuppressWarnings("RedundantThrows") + public static String dumpDecoders() throws Exception { + String str = ""; + for (MediaCodecInfo codecInfo : getMediaCodecList()) { + // Skip encoders + if (codecInfo.isEncoder()) { + continue; + } + + str += "Decoder: "+codecInfo.getName()+"\n"; + for (String type : codecInfo.getSupportedTypes()) { + str += "\t"+type+"\n"; + CodecCapabilities caps = codecInfo.getCapabilitiesForType(type); + + for (CodecProfileLevel profile : caps.profileLevels) { + str += "\t\t"+profile.profile+" "+profile.level+"\n"; + } + } + } + return str; + } + + private static MediaCodecInfo findPreferredDecoder() { + // This is a different algorithm than the other findXXXDecoder functions, + // because we want to evaluate the decoders in our list's order + // rather than MediaCodecList's order + + if (!initialized) { + throw new IllegalStateException("MediaCodecHelper must be initialized before use"); + } + + for (String preferredDecoder : preferredDecoders) { + for (MediaCodecInfo codecInfo : getMediaCodecList()) { + // Skip encoders + if (codecInfo.isEncoder()) { + continue; + } + + // Check for preferred decoders + if (preferredDecoder.equalsIgnoreCase(codecInfo.getName())) { + LimeLog.info("Preferred decoder choice is "+codecInfo.getName()); + return codecInfo; + } + } + } + + return null; + } + + private static boolean isCodecBlacklisted(MediaCodecInfo codecInfo) { + // Use the new isSoftwareOnly() function on Android Q + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + if (!SHOULD_BYPASS_SOFTWARE_BLOCK && codecInfo.isSoftwareOnly()) { + LimeLog.info("Skipping software-only decoder: "+codecInfo.getName()); + return true; + } + } + + // Check for explicitly blacklisted decoders + if (isDecoderInList(blacklistedDecoderPrefixes, codecInfo.getName())) { + LimeLog.info("Skipping blacklisted decoder: "+codecInfo.getName()); + return true; + } + + return false; + } + + public static MediaCodecInfo findFirstDecoder(String mimeType) { + for (MediaCodecInfo codecInfo : getMediaCodecList()) { + // Skip encoders + if (codecInfo.isEncoder()) { + continue; + } + + // Skip compatibility aliases on Q+ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + if (codecInfo.isAlias()) { + continue; + } + } + + // Find a decoder that supports the specified video format + for (String mime : codecInfo.getSupportedTypes()) { + if (mime.equalsIgnoreCase(mimeType)) { + // Skip blacklisted codecs + if (isCodecBlacklisted(codecInfo)) { + continue; + } + + LimeLog.info("First decoder choice is "+codecInfo.getName()); + return codecInfo; + } + } + } + + return null; + } + + public static MediaCodecInfo findProbableSafeDecoder(String mimeType, int requiredProfile) { + // First look for a preferred decoder by name + MediaCodecInfo info = findPreferredDecoder(); + if (info != null) { + return info; + } + + // Now look for decoders we know are safe + try { + // If this function completes, it will determine if the decoder is safe + return findKnownSafeDecoder(mimeType, requiredProfile); + } catch (Exception e) { + // Some buggy devices seem to throw exceptions + // from getCapabilitiesForType() so we'll just assume + // they're okay and go with the first one we find + return findFirstDecoder(mimeType); + } + } + + // We declare this method as explicitly throwing Exception + // since some bad decoders can throw IllegalArgumentExceptions unexpectedly + // and we want to be sure all callers are handling this possibility + @SuppressWarnings("RedundantThrows") + private static MediaCodecInfo findKnownSafeDecoder(String mimeType, int requiredProfile) throws Exception { + // Some devices (Exynos devces, at least) have two sets of decoders. + // The first set of decoders are C2 which do not support FEATURE_LowLatency, + // but the second set of OMX decoders do support FEATURE_LowLatency. We want + // to pick the OMX decoders despite the fact that C2 is listed first. + // On some Qualcomm devices (like Pixel 4), there are separate low latency decoders + // (like c2.qti.hevc.decoder.low_latency) that advertise FEATURE_LowLatency while + // the standard ones (like c2.qti.hevc.decoder) do not. Like Exynos, the decoders + // with FEATURE_LowLatency support are listed after the standard ones. + for (int i = 0; i < 2; i++) { + for (MediaCodecInfo codecInfo : getMediaCodecList()) { + // Skip encoders + if (codecInfo.isEncoder()) { + continue; + } + + // Skip compatibility aliases on Q+ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + if (codecInfo.isAlias()) { + continue; + } + } + + // Find a decoder that supports the requested video format + for (String mime : codecInfo.getSupportedTypes()) { + if (mime.equalsIgnoreCase(mimeType)) { + LimeLog.info("Examining decoder capabilities of " + codecInfo.getName() + " (round " + (i + 1) + ")"); + + // Skip blacklisted codecs + if (isCodecBlacklisted(codecInfo)) { + continue; + } + + CodecCapabilities caps = codecInfo.getCapabilitiesForType(mime); + + if (i == 0 && !decoderSupportsAndroidRLowLatency(codecInfo, mime)) { + LimeLog.info("Skipping decoder that lacks FEATURE_LowLatency for round 1"); + continue; + } + + if (requiredProfile != -1) { + for (CodecProfileLevel profile : caps.profileLevels) { + if (profile.profile == requiredProfile) { + LimeLog.info("Decoder " + codecInfo.getName() + " supports required profile"); + return codecInfo; + } + } + + LimeLog.info("Decoder " + codecInfo.getName() + " does NOT support required profile"); + } else { + return codecInfo; + } + } + } + } + } + + return null; + } + + public static String readCpuinfo() throws Exception { + StringBuilder cpuInfo = new StringBuilder(); + try (final BufferedReader br = new BufferedReader(new FileReader(new File("/proc/cpuinfo")))) { + for (;;) { + int ch = br.read(); + if (ch == -1) + break; + cpuInfo.append((char)ch); + } + + return cpuInfo.toString(); + } + } + + private static boolean stringContainsIgnoreCase(String string, String substring) { + return string.toLowerCase(Locale.ENGLISH).contains(substring.toLowerCase(Locale.ENGLISH)); + } + + public static boolean isExynos4Device() { + try { + // Try reading CPU info too look for + String cpuInfo = readCpuinfo(); + + // SMDK4xxx is Exynos 4 + if (stringContainsIgnoreCase(cpuInfo, "SMDK4")) { + LimeLog.info("Found SMDK4 in /proc/cpuinfo"); + return true; + } + + // If we see "Exynos 4" also we'll count it + if (stringContainsIgnoreCase(cpuInfo, "Exynos 4")) { + LimeLog.info("Found Exynos 4 in /proc/cpuinfo"); + return true; + } + } catch (Exception e) { + e.printStackTrace(); + } + + try { + File systemDir = new File("/sys/devices/system"); + File[] files = systemDir.listFiles(); + if (files != null) { + for (File f : files) { + if (stringContainsIgnoreCase(f.getName(), "exynos4")) { + LimeLog.info("Found exynos4 in /sys/devices/system"); + return true; + } + } + } + } catch (Exception e) { + e.printStackTrace(); + } + + return false; + } +} diff --git a/app/src/main/java/com/limelight/binding/video/PerfOverlayListener.java b/app/src/main/java/com/limelight/binding/video/PerfOverlayListener.java old mode 100644 new mode 100755 index 281f95a046..fce697550d --- a/app/src/main/java/com/limelight/binding/video/PerfOverlayListener.java +++ b/app/src/main/java/com/limelight/binding/video/PerfOverlayListener.java @@ -1,5 +1,5 @@ -package com.limelight.binding.video; - -public interface PerfOverlayListener { - void onPerfUpdate(final String text); -} +package com.limelight.binding.video; + +public interface PerfOverlayListener { + void onPerfUpdate(final String text); +} diff --git a/app/src/main/java/com/limelight/binding/video/VideoStats.java b/app/src/main/java/com/limelight/binding/video/VideoStats.java old mode 100644 new mode 100755 index b65b897ecf..d3022f2c73 --- a/app/src/main/java/com/limelight/binding/video/VideoStats.java +++ b/app/src/main/java/com/limelight/binding/video/VideoStats.java @@ -1,93 +1,93 @@ -package com.limelight.binding.video; - -import android.os.SystemClock; - -class VideoStats { - - long decoderTimeMs; - long totalTimeMs; - int totalFrames; - int totalFramesReceived; - int totalFramesRendered; - int frameLossEvents; - int framesLost; - char minHostProcessingLatency; - char maxHostProcessingLatency; - int totalHostProcessingLatency; - int framesWithHostProcessingLatency; - long measurementStartTimestamp; - - void add(VideoStats other) { - this.decoderTimeMs += other.decoderTimeMs; - this.totalTimeMs += other.totalTimeMs; - this.totalFrames += other.totalFrames; - this.totalFramesReceived += other.totalFramesReceived; - this.totalFramesRendered += other.totalFramesRendered; - this.frameLossEvents += other.frameLossEvents; - this.framesLost += other.framesLost; - - if (this.minHostProcessingLatency == 0) { - this.minHostProcessingLatency = other.minHostProcessingLatency; - } else { - this.minHostProcessingLatency = (char) Math.min(this.minHostProcessingLatency, other.minHostProcessingLatency); - } - this.maxHostProcessingLatency = (char) Math.max(this.maxHostProcessingLatency, other.maxHostProcessingLatency); - this.totalHostProcessingLatency += other.totalHostProcessingLatency; - this.framesWithHostProcessingLatency += other.framesWithHostProcessingLatency; - - if (this.measurementStartTimestamp == 0) { - this.measurementStartTimestamp = other.measurementStartTimestamp; - } - - assert other.measurementStartTimestamp >= this.measurementStartTimestamp; - } - - void copy(VideoStats other) { - this.decoderTimeMs = other.decoderTimeMs; - this.totalTimeMs = other.totalTimeMs; - this.totalFrames = other.totalFrames; - this.totalFramesReceived = other.totalFramesReceived; - this.totalFramesRendered = other.totalFramesRendered; - this.frameLossEvents = other.frameLossEvents; - this.framesLost = other.framesLost; - this.minHostProcessingLatency = other.minHostProcessingLatency; - this.maxHostProcessingLatency = other.maxHostProcessingLatency; - this.totalHostProcessingLatency = other.totalHostProcessingLatency; - this.framesWithHostProcessingLatency = other.framesWithHostProcessingLatency; - this.measurementStartTimestamp = other.measurementStartTimestamp; - } - - void clear() { - this.decoderTimeMs = 0; - this.totalTimeMs = 0; - this.totalFrames = 0; - this.totalFramesReceived = 0; - this.totalFramesRendered = 0; - this.frameLossEvents = 0; - this.framesLost = 0; - this.minHostProcessingLatency = 0; - this.maxHostProcessingLatency = 0; - this.totalHostProcessingLatency = 0; - this.framesWithHostProcessingLatency = 0; - this.measurementStartTimestamp = 0; - } - - VideoStatsFps getFps() { - float elapsed = (SystemClock.uptimeMillis() - this.measurementStartTimestamp) / (float) 1000; - - VideoStatsFps fps = new VideoStatsFps(); - if (elapsed > 0) { - fps.totalFps = this.totalFrames / elapsed; - fps.receivedFps = this.totalFramesReceived / elapsed; - fps.renderedFps = this.totalFramesRendered / elapsed; - } - return fps; - } -} - -class VideoStatsFps { - - float totalFps; - float receivedFps; - float renderedFps; +package com.limelight.binding.video; + +import android.os.SystemClock; + +class VideoStats { + + long decoderTimeMs; + long totalTimeMs; + int totalFrames; + int totalFramesReceived; + int totalFramesRendered; + int frameLossEvents; + int framesLost; + char minHostProcessingLatency; + char maxHostProcessingLatency; + int totalHostProcessingLatency; + int framesWithHostProcessingLatency; + long measurementStartTimestamp; + + void add(VideoStats other) { + this.decoderTimeMs += other.decoderTimeMs; + this.totalTimeMs += other.totalTimeMs; + this.totalFrames += other.totalFrames; + this.totalFramesReceived += other.totalFramesReceived; + this.totalFramesRendered += other.totalFramesRendered; + this.frameLossEvents += other.frameLossEvents; + this.framesLost += other.framesLost; + + if (this.minHostProcessingLatency == 0) { + this.minHostProcessingLatency = other.minHostProcessingLatency; + } else { + this.minHostProcessingLatency = (char) Math.min(this.minHostProcessingLatency, other.minHostProcessingLatency); + } + this.maxHostProcessingLatency = (char) Math.max(this.maxHostProcessingLatency, other.maxHostProcessingLatency); + this.totalHostProcessingLatency += other.totalHostProcessingLatency; + this.framesWithHostProcessingLatency += other.framesWithHostProcessingLatency; + + if (this.measurementStartTimestamp == 0) { + this.measurementStartTimestamp = other.measurementStartTimestamp; + } + + assert other.measurementStartTimestamp >= this.measurementStartTimestamp; + } + + void copy(VideoStats other) { + this.decoderTimeMs = other.decoderTimeMs; + this.totalTimeMs = other.totalTimeMs; + this.totalFrames = other.totalFrames; + this.totalFramesReceived = other.totalFramesReceived; + this.totalFramesRendered = other.totalFramesRendered; + this.frameLossEvents = other.frameLossEvents; + this.framesLost = other.framesLost; + this.minHostProcessingLatency = other.minHostProcessingLatency; + this.maxHostProcessingLatency = other.maxHostProcessingLatency; + this.totalHostProcessingLatency = other.totalHostProcessingLatency; + this.framesWithHostProcessingLatency = other.framesWithHostProcessingLatency; + this.measurementStartTimestamp = other.measurementStartTimestamp; + } + + void clear() { + this.decoderTimeMs = 0; + this.totalTimeMs = 0; + this.totalFrames = 0; + this.totalFramesReceived = 0; + this.totalFramesRendered = 0; + this.frameLossEvents = 0; + this.framesLost = 0; + this.minHostProcessingLatency = 0; + this.maxHostProcessingLatency = 0; + this.totalHostProcessingLatency = 0; + this.framesWithHostProcessingLatency = 0; + this.measurementStartTimestamp = 0; + } + + VideoStatsFps getFps() { + float elapsed = (SystemClock.uptimeMillis() - this.measurementStartTimestamp) / (float) 1000; + + VideoStatsFps fps = new VideoStatsFps(); + if (elapsed > 0) { + fps.totalFps = this.totalFrames / elapsed; + fps.receivedFps = this.totalFramesReceived / elapsed; + fps.renderedFps = this.totalFramesRendered / elapsed; + } + return fps; + } +} + +class VideoStatsFps { + + float totalFps; + float receivedFps; + float renderedFps; } \ No newline at end of file diff --git a/app/src/main/java/com/limelight/computers/ComputerDatabaseManager.java b/app/src/main/java/com/limelight/computers/ComputerDatabaseManager.java old mode 100644 new mode 100755 index 54e656af1c..94cfa77cf5 --- a/app/src/main/java/com/limelight/computers/ComputerDatabaseManager.java +++ b/app/src/main/java/com/limelight/computers/ComputerDatabaseManager.java @@ -1,206 +1,206 @@ -package com.limelight.computers; - -import java.io.ByteArrayInputStream; -import java.security.cert.CertificateEncodingException; -import java.security.cert.CertificateException; -import java.security.cert.CertificateFactory; -import java.security.cert.X509Certificate; -import java.util.LinkedList; -import java.util.List; -import java.util.Locale; - -import com.limelight.LimeLog; -import com.limelight.nvstream.http.ComputerDetails; -import com.limelight.nvstream.http.NvHTTP; - -import android.content.ContentValues; -import android.content.Context; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; -import android.database.sqlite.SQLiteException; - -import org.json.JSONException; -import org.json.JSONObject; - -public class ComputerDatabaseManager { - private static final String COMPUTER_DB_NAME = "computers4.db"; - private static final String COMPUTER_TABLE_NAME = "Computers"; - private static final String COMPUTER_UUID_COLUMN_NAME = "UUID"; - private static final String COMPUTER_NAME_COLUMN_NAME = "ComputerName"; - private static final String ADDRESSES_COLUMN_NAME = "Addresses"; - private interface AddressFields { - String LOCAL = "local"; - String REMOTE = "remote"; - String MANUAL = "manual"; - String IPv6 = "ipv6"; - - String ADDRESS = "address"; - String PORT = "port"; - } - - private static final String MAC_ADDRESS_COLUMN_NAME = "MacAddress"; - private static final String SERVER_CERT_COLUMN_NAME = "ServerCert"; - - private SQLiteDatabase computerDb; - - public ComputerDatabaseManager(Context c) { - try { - // Create or open an existing DB - computerDb = c.openOrCreateDatabase(COMPUTER_DB_NAME, 0, null); - } catch (SQLiteException e) { - // Delete the DB and try again - c.deleteDatabase(COMPUTER_DB_NAME); - computerDb = c.openOrCreateDatabase(COMPUTER_DB_NAME, 0, null); - } - initializeDb(c); - } - - public void close() { - computerDb.close(); - } - - private void initializeDb(Context c) { - // Create tables if they aren't already there - computerDb.execSQL(String.format((Locale)null, - "CREATE TABLE IF NOT EXISTS %s(%s TEXT PRIMARY KEY, %s TEXT NOT NULL, %s TEXT NOT NULL, %s TEXT, %s TEXT)", - COMPUTER_TABLE_NAME, COMPUTER_UUID_COLUMN_NAME, COMPUTER_NAME_COLUMN_NAME, - ADDRESSES_COLUMN_NAME, MAC_ADDRESS_COLUMN_NAME, SERVER_CERT_COLUMN_NAME)); - - // Move all computers from the old DB (if any) to the new one - List oldComputers = LegacyDatabaseReader.migrateAllComputers(c); - for (ComputerDetails computer : oldComputers) { - updateComputer(computer); - } - oldComputers = LegacyDatabaseReader2.migrateAllComputers(c); - for (ComputerDetails computer : oldComputers) { - updateComputer(computer); - } - oldComputers = LegacyDatabaseReader3.migrateAllComputers(c); - for (ComputerDetails computer : oldComputers) { - updateComputer(computer); - } - } - - public void deleteComputer(ComputerDetails details) { - computerDb.delete(COMPUTER_TABLE_NAME, COMPUTER_UUID_COLUMN_NAME+"=?", new String[]{details.uuid}); - } - - public static JSONObject tupleToJson(ComputerDetails.AddressTuple tuple) throws JSONException { - if (tuple == null) { - return null; - } - - JSONObject json = new JSONObject(); - json.put(AddressFields.ADDRESS, tuple.address); - json.put(AddressFields.PORT, tuple.port); - - return json; - } - - public static ComputerDetails.AddressTuple tupleFromJson(JSONObject json, String name) throws JSONException { - if (!json.has(name)) { - return null; - } - - JSONObject address = json.getJSONObject(name); - return new ComputerDetails.AddressTuple( - address.getString(AddressFields.ADDRESS), address.getInt(AddressFields.PORT)); - } - - public boolean updateComputer(ComputerDetails details) { - ContentValues values = new ContentValues(); - values.put(COMPUTER_UUID_COLUMN_NAME, details.uuid); - values.put(COMPUTER_NAME_COLUMN_NAME, details.name); - - try { - JSONObject addresses = new JSONObject(); - addresses.put(AddressFields.LOCAL, tupleToJson(details.localAddress)); - addresses.put(AddressFields.REMOTE, tupleToJson(details.remoteAddress)); - addresses.put(AddressFields.MANUAL, tupleToJson(details.manualAddress)); - addresses.put(AddressFields.IPv6, tupleToJson(details.ipv6Address)); - values.put(ADDRESSES_COLUMN_NAME, addresses.toString()); - } catch (JSONException e) { - throw new RuntimeException(e); - } - - values.put(MAC_ADDRESS_COLUMN_NAME, details.macAddress); - try { - if (details.serverCert != null) { - values.put(SERVER_CERT_COLUMN_NAME, details.serverCert.getEncoded()); - } - else { - values.put(SERVER_CERT_COLUMN_NAME, (byte[])null); - } - } catch (CertificateEncodingException e) { - values.put(SERVER_CERT_COLUMN_NAME, (byte[])null); - e.printStackTrace(); - } - return -1 != computerDb.insertWithOnConflict(COMPUTER_TABLE_NAME, null, values, SQLiteDatabase.CONFLICT_REPLACE); - } - - private ComputerDetails getComputerFromCursor(Cursor c) { - ComputerDetails details = new ComputerDetails(); - - details.uuid = c.getString(0); - details.name = c.getString(1); - try { - JSONObject addresses = new JSONObject(c.getString(2)); - details.localAddress = tupleFromJson(addresses, AddressFields.LOCAL); - details.remoteAddress = tupleFromJson(addresses, AddressFields.REMOTE); - details.manualAddress = tupleFromJson(addresses, AddressFields.MANUAL); - details.ipv6Address = tupleFromJson(addresses, AddressFields.IPv6); - } catch (JSONException e) { - throw new RuntimeException(e); - } - - // External port is persisted in the remote address field - if (details.remoteAddress != null) { - details.externalPort = details.remoteAddress.port; - } - else { - details.externalPort = NvHTTP.DEFAULT_HTTP_PORT; - } - - details.macAddress = c.getString(3); - - try { - byte[] derCertData = c.getBlob(4); - - if (derCertData != null) { - details.serverCert = (X509Certificate) CertificateFactory.getInstance("X.509") - .generateCertificate(new ByteArrayInputStream(derCertData)); - } - } catch (CertificateException e) { - e.printStackTrace(); - } - - // This signifies we don't have dynamic state (like pair state) - details.state = ComputerDetails.State.UNKNOWN; - - return details; - } - - public List getAllComputers() { - try (final Cursor c = computerDb.rawQuery("SELECT * FROM "+COMPUTER_TABLE_NAME, null)) { - LinkedList computerList = new LinkedList<>(); - while (c.moveToNext()) { - computerList.add(getComputerFromCursor(c)); - } - return computerList; - } - } - - public ComputerDetails getComputerByUUID(String uuid) { - try (final Cursor c = computerDb.query( - COMPUTER_TABLE_NAME, null, COMPUTER_UUID_COLUMN_NAME+"=?", - new String[]{ uuid }, null, null, null) - ) { - if (!c.moveToFirst()) { - // No matching computer - return null; - } - - return getComputerFromCursor(c); - } - } -} +package com.limelight.computers; + +import java.io.ByteArrayInputStream; +import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; + +import com.limelight.LimeLog; +import com.limelight.nvstream.http.ComputerDetails; +import com.limelight.nvstream.http.NvHTTP; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteException; + +import org.json.JSONException; +import org.json.JSONObject; + +public class ComputerDatabaseManager { + private static final String COMPUTER_DB_NAME = "computers4.db"; + private static final String COMPUTER_TABLE_NAME = "Computers"; + private static final String COMPUTER_UUID_COLUMN_NAME = "UUID"; + private static final String COMPUTER_NAME_COLUMN_NAME = "ComputerName"; + private static final String ADDRESSES_COLUMN_NAME = "Addresses"; + private interface AddressFields { + String LOCAL = "local"; + String REMOTE = "remote"; + String MANUAL = "manual"; + String IPv6 = "ipv6"; + + String ADDRESS = "address"; + String PORT = "port"; + } + + private static final String MAC_ADDRESS_COLUMN_NAME = "MacAddress"; + private static final String SERVER_CERT_COLUMN_NAME = "ServerCert"; + + private SQLiteDatabase computerDb; + + public ComputerDatabaseManager(Context c) { + try { + // Create or open an existing DB + computerDb = c.openOrCreateDatabase(COMPUTER_DB_NAME, 0, null); + } catch (SQLiteException e) { + // Delete the DB and try again + c.deleteDatabase(COMPUTER_DB_NAME); + computerDb = c.openOrCreateDatabase(COMPUTER_DB_NAME, 0, null); + } + initializeDb(c); + } + + public void close() { + computerDb.close(); + } + + private void initializeDb(Context c) { + // Create tables if they aren't already there + computerDb.execSQL(String.format((Locale)null, + "CREATE TABLE IF NOT EXISTS %s(%s TEXT PRIMARY KEY, %s TEXT NOT NULL, %s TEXT NOT NULL, %s TEXT, %s TEXT)", + COMPUTER_TABLE_NAME, COMPUTER_UUID_COLUMN_NAME, COMPUTER_NAME_COLUMN_NAME, + ADDRESSES_COLUMN_NAME, MAC_ADDRESS_COLUMN_NAME, SERVER_CERT_COLUMN_NAME)); + + // Move all computers from the old DB (if any) to the new one + List oldComputers = LegacyDatabaseReader.migrateAllComputers(c); + for (ComputerDetails computer : oldComputers) { + updateComputer(computer); + } + oldComputers = LegacyDatabaseReader2.migrateAllComputers(c); + for (ComputerDetails computer : oldComputers) { + updateComputer(computer); + } + oldComputers = LegacyDatabaseReader3.migrateAllComputers(c); + for (ComputerDetails computer : oldComputers) { + updateComputer(computer); + } + } + + public void deleteComputer(ComputerDetails details) { + computerDb.delete(COMPUTER_TABLE_NAME, COMPUTER_UUID_COLUMN_NAME+"=?", new String[]{details.uuid}); + } + + public static JSONObject tupleToJson(ComputerDetails.AddressTuple tuple) throws JSONException { + if (tuple == null) { + return null; + } + + JSONObject json = new JSONObject(); + json.put(AddressFields.ADDRESS, tuple.address); + json.put(AddressFields.PORT, tuple.port); + + return json; + } + + public static ComputerDetails.AddressTuple tupleFromJson(JSONObject json, String name) throws JSONException { + if (!json.has(name)) { + return null; + } + + JSONObject address = json.getJSONObject(name); + return new ComputerDetails.AddressTuple( + address.getString(AddressFields.ADDRESS), address.getInt(AddressFields.PORT)); + } + + public boolean updateComputer(ComputerDetails details) { + ContentValues values = new ContentValues(); + values.put(COMPUTER_UUID_COLUMN_NAME, details.uuid); + values.put(COMPUTER_NAME_COLUMN_NAME, details.name); + + try { + JSONObject addresses = new JSONObject(); + addresses.put(AddressFields.LOCAL, tupleToJson(details.localAddress)); + addresses.put(AddressFields.REMOTE, tupleToJson(details.remoteAddress)); + addresses.put(AddressFields.MANUAL, tupleToJson(details.manualAddress)); + addresses.put(AddressFields.IPv6, tupleToJson(details.ipv6Address)); + values.put(ADDRESSES_COLUMN_NAME, addresses.toString()); + } catch (JSONException e) { + throw new RuntimeException(e); + } + + values.put(MAC_ADDRESS_COLUMN_NAME, details.macAddress); + try { + if (details.serverCert != null) { + values.put(SERVER_CERT_COLUMN_NAME, details.serverCert.getEncoded()); + } + else { + values.put(SERVER_CERT_COLUMN_NAME, (byte[])null); + } + } catch (CertificateEncodingException e) { + values.put(SERVER_CERT_COLUMN_NAME, (byte[])null); + e.printStackTrace(); + } + return -1 != computerDb.insertWithOnConflict(COMPUTER_TABLE_NAME, null, values, SQLiteDatabase.CONFLICT_REPLACE); + } + + private ComputerDetails getComputerFromCursor(Cursor c) { + ComputerDetails details = new ComputerDetails(); + + details.uuid = c.getString(0); + details.name = c.getString(1); + try { + JSONObject addresses = new JSONObject(c.getString(2)); + details.localAddress = tupleFromJson(addresses, AddressFields.LOCAL); + details.remoteAddress = tupleFromJson(addresses, AddressFields.REMOTE); + details.manualAddress = tupleFromJson(addresses, AddressFields.MANUAL); + details.ipv6Address = tupleFromJson(addresses, AddressFields.IPv6); + } catch (JSONException e) { + throw new RuntimeException(e); + } + + // External port is persisted in the remote address field + if (details.remoteAddress != null) { + details.externalPort = details.remoteAddress.port; + } + else { + details.externalPort = NvHTTP.DEFAULT_HTTP_PORT; + } + + details.macAddress = c.getString(3); + + try { + byte[] derCertData = c.getBlob(4); + + if (derCertData != null) { + details.serverCert = (X509Certificate) CertificateFactory.getInstance("X.509") + .generateCertificate(new ByteArrayInputStream(derCertData)); + } + } catch (CertificateException e) { + e.printStackTrace(); + } + + // This signifies we don't have dynamic state (like pair state) + details.state = ComputerDetails.State.UNKNOWN; + + return details; + } + + public List getAllComputers() { + try (final Cursor c = computerDb.rawQuery("SELECT * FROM "+COMPUTER_TABLE_NAME, null)) { + LinkedList computerList = new LinkedList<>(); + while (c.moveToNext()) { + computerList.add(getComputerFromCursor(c)); + } + return computerList; + } + } + + public ComputerDetails getComputerByUUID(String uuid) { + try (final Cursor c = computerDb.query( + COMPUTER_TABLE_NAME, null, COMPUTER_UUID_COLUMN_NAME+"=?", + new String[]{ uuid }, null, null, null) + ) { + if (!c.moveToFirst()) { + // No matching computer + return null; + } + + return getComputerFromCursor(c); + } + } +} diff --git a/app/src/main/java/com/limelight/computers/ComputerManagerListener.java b/app/src/main/java/com/limelight/computers/ComputerManagerListener.java old mode 100644 new mode 100755 index 263f9fb39b..f23dcc467a --- a/app/src/main/java/com/limelight/computers/ComputerManagerListener.java +++ b/app/src/main/java/com/limelight/computers/ComputerManagerListener.java @@ -1,7 +1,7 @@ -package com.limelight.computers; - -import com.limelight.nvstream.http.ComputerDetails; - -public interface ComputerManagerListener { - void notifyComputerUpdated(ComputerDetails details); -} +package com.limelight.computers; + +import com.limelight.nvstream.http.ComputerDetails; + +public interface ComputerManagerListener { + void notifyComputerUpdated(ComputerDetails details); +} diff --git a/app/src/main/java/com/limelight/computers/ComputerManagerService.java b/app/src/main/java/com/limelight/computers/ComputerManagerService.java old mode 100644 new mode 100755 index 990a8953f2..d2c39026bd --- a/app/src/main/java/com/limelight/computers/ComputerManagerService.java +++ b/app/src/main/java/com/limelight/computers/ComputerManagerService.java @@ -1,969 +1,969 @@ -package com.limelight.computers; - -import java.io.IOException; -import java.io.OutputStream; -import java.io.StringReader; -import java.net.Inet4Address; -import java.net.InetAddress; -import java.net.UnknownHostException; -import java.util.HashSet; -import java.util.LinkedList; -import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.locks.Lock; -import java.util.concurrent.locks.ReentrantLock; - -import com.limelight.LimeLog; -import com.limelight.binding.PlatformBinding; -import com.limelight.discovery.DiscoveryService; -import com.limelight.nvstream.NvConnection; -import com.limelight.nvstream.http.ComputerDetails; -import com.limelight.nvstream.http.NvApp; -import com.limelight.nvstream.http.NvHTTP; -import com.limelight.nvstream.http.PairingManager; -import com.limelight.nvstream.mdns.MdnsComputer; -import com.limelight.nvstream.mdns.MdnsDiscoveryListener; -import com.limelight.utils.CacheHelper; -import com.limelight.utils.NetHelper; -import com.limelight.utils.ServerHelper; - -import android.app.Service; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.ServiceConnection; -import android.net.ConnectivityManager; -import android.net.Network; -import android.net.NetworkCapabilities; -import android.os.Binder; -import android.os.Build; -import android.os.IBinder; -import android.os.SystemClock; - -import org.xmlpull.v1.XmlPullParserException; - -public class ComputerManagerService extends Service { - private static final int SERVERINFO_POLLING_PERIOD_MS = 1500; - private static final int APPLIST_POLLING_PERIOD_MS = 30000; - private static final int APPLIST_FAILED_POLLING_RETRY_MS = 2000; - private static final int MDNS_QUERY_PERIOD_MS = 1000; - private static final int OFFLINE_POLL_TRIES = 3; - private static final int INITIAL_POLL_TRIES = 2; - private static final int EMPTY_LIST_THRESHOLD = 3; - private static final int POLL_DATA_TTL_MS = 30000; - - private final ComputerManagerBinder binder = new ComputerManagerBinder(); - - private ComputerDatabaseManager dbManager; - private final AtomicInteger dbRefCount = new AtomicInteger(0); - - private IdentityManager idManager; - private final LinkedList pollingTuples = new LinkedList<>(); - private ComputerManagerListener listener = null; - private final AtomicInteger activePolls = new AtomicInteger(0); - private boolean pollingActive = false; - private final Lock defaultNetworkLock = new ReentrantLock(); - - private ConnectivityManager.NetworkCallback networkCallback; - - private DiscoveryService.DiscoveryBinder discoveryBinder; - private final ServiceConnection discoveryServiceConnection = new ServiceConnection() { - public void onServiceConnected(ComponentName className, IBinder binder) { - synchronized (discoveryServiceConnection) { - DiscoveryService.DiscoveryBinder privateBinder = ((DiscoveryService.DiscoveryBinder)binder); - - // Set us as the event listener - privateBinder.setListener(createDiscoveryListener()); - - // Signal a possible waiter that we're all setup - discoveryBinder = privateBinder; - discoveryServiceConnection.notifyAll(); - } - } - - public void onServiceDisconnected(ComponentName className) { - discoveryBinder = null; - } - }; - - // Returns true if the details object was modified - private boolean runPoll(ComputerDetails details, boolean newPc, int offlineCount) throws InterruptedException { - if (!getLocalDatabaseReference()) { - return false; - } - - final int pollTriesBeforeOffline = details.state == ComputerDetails.State.UNKNOWN ? - INITIAL_POLL_TRIES : OFFLINE_POLL_TRIES; - - activePolls.incrementAndGet(); - - // Poll the machine - try { - if (!pollComputer(details)) { - if (!newPc && offlineCount < pollTriesBeforeOffline) { - // Return without calling the listener - releaseLocalDatabaseReference(); - return false; - } - - details.state = ComputerDetails.State.OFFLINE; - } - } catch (InterruptedException e) { - releaseLocalDatabaseReference(); - throw e; - } finally { - activePolls.decrementAndGet(); - } - - // If it's online, update our persistent state - if (details.state == ComputerDetails.State.ONLINE) { - ComputerDetails existingComputer = dbManager.getComputerByUUID(details.uuid); - - // Check if it's in the database because it could have been - // removed after this was issued - if (!newPc && existingComputer == null) { - // It's gone - releaseLocalDatabaseReference(); - return false; - } - - // If we already have an entry for this computer in the DB, we must - // combine the existing data with this new data (which may be partially available - // due to detecting the PC via mDNS) without the saved external address. If we - // write to the DB without doing this first, we can overwrite our existing data. - if (existingComputer != null) { - existingComputer.update(details); - dbManager.updateComputer(existingComputer); - } - else { - try { - // If the active address is a site-local address (RFC 1918), - // then use STUN to populate the external address field if - // it's not set already. - if (details.remoteAddress == null) { - InetAddress addr = InetAddress.getByName(details.activeAddress.address); - if (addr.isSiteLocalAddress()) { - populateExternalAddress(details); - } - } - } catch (UnknownHostException ignored) {} - - dbManager.updateComputer(details); - } - } - - // Don't call the listener if this is a failed lookup of a new PC - if ((!newPc || details.state == ComputerDetails.State.ONLINE) && listener != null) { - listener.notifyComputerUpdated(details); - } - - releaseLocalDatabaseReference(); - return true; - } - - private Thread createPollingThread(final PollingTuple tuple) { - Thread t = new Thread() { - @Override - public void run() { - - int offlineCount = 0; - while (!isInterrupted() && pollingActive && tuple.thread == this) { - try { - // Only allow one request to the machine at a time - synchronized (tuple.networkLock) { - // Check if this poll has modified the details - if (!runPoll(tuple.computer, false, offlineCount)) { - LimeLog.warning(tuple.computer.name + " is offline (try " + offlineCount + ")"); - offlineCount++; - } else { - tuple.lastSuccessfulPollMs = SystemClock.elapsedRealtime(); - offlineCount = 0; - } - } - - // Wait until the next polling interval - Thread.sleep(SERVERINFO_POLLING_PERIOD_MS); - } catch (InterruptedException e) { - break; - } - } - } - }; - t.setName("Polling thread for " + tuple.computer.name); - return t; - } - - public class ComputerManagerBinder extends Binder { - public void startPolling(ComputerManagerListener listener) { - // Polling is active - pollingActive = true; - - // Set the listener - ComputerManagerService.this.listener = listener; - - // Start mDNS autodiscovery too - discoveryBinder.startDiscovery(MDNS_QUERY_PERIOD_MS); - - synchronized (pollingTuples) { - for (PollingTuple tuple : pollingTuples) { - // Enforce the poll data TTL - if (SystemClock.elapsedRealtime() - tuple.lastSuccessfulPollMs > POLL_DATA_TTL_MS) { - LimeLog.info("Timing out polled state for "+tuple.computer.name); - tuple.computer.state = ComputerDetails.State.UNKNOWN; - } - - // Report this computer initially - listener.notifyComputerUpdated(tuple.computer); - - // This polling thread might already be there - if (tuple.thread == null) { - tuple.thread = createPollingThread(tuple); - tuple.thread.start(); - } - } - } - } - - public void waitForReady() { - synchronized (discoveryServiceConnection) { - try { - while (discoveryBinder == null) { - // Wait for the bind notification - discoveryServiceConnection.wait(1000); - } - } catch (InterruptedException e) { - e.printStackTrace(); - - // InterruptedException clears the thread's interrupt status. Since we can't - // handle that here, we will re-interrupt the thread to set the interrupt - // status back to true. - Thread.currentThread().interrupt(); - } - } - } - - public void waitForPollingStopped() { - while (activePolls.get() != 0) { - try { - Thread.sleep(250); - } catch (InterruptedException e) { - e.printStackTrace(); - - // InterruptedException clears the thread's interrupt status. Since we can't - // handle that here, we will re-interrupt the thread to set the interrupt - // status back to true. - Thread.currentThread().interrupt(); - } - } - } - - public boolean addComputerBlocking(ComputerDetails fakeDetails) throws InterruptedException { - return ComputerManagerService.this.addComputerBlocking(fakeDetails); - } - - public void removeComputer(ComputerDetails computer) { - ComputerManagerService.this.removeComputer(computer); - } - - public void stopPolling() { - // Just call the unbind handler to cleanup - ComputerManagerService.this.onUnbind(null); - } - - public ApplistPoller createAppListPoller(ComputerDetails computer) { - return new ApplistPoller(computer); - } - - public String getUniqueId() { - return idManager.getUniqueId(); - } - - public ComputerDetails getComputer(String uuid) { - synchronized (pollingTuples) { - for (PollingTuple tuple : pollingTuples) { - if (uuid.equals(tuple.computer.uuid)) { - return tuple.computer; - } - } - } - - return null; - } - - public void invalidateStateForComputer(String uuid) { - synchronized (pollingTuples) { - for (PollingTuple tuple : pollingTuples) { - if (uuid.equals(tuple.computer.uuid)) { - // We need the network lock to prevent a concurrent poll - // from wiping this change out - synchronized (tuple.networkLock) { - tuple.computer.state = ComputerDetails.State.UNKNOWN; - } - } - } - } - } - } - - @Override - public boolean onUnbind(Intent intent) { - if (discoveryBinder != null) { - // Stop mDNS autodiscovery - discoveryBinder.stopDiscovery(); - } - - // Stop polling - pollingActive = false; - synchronized (pollingTuples) { - for (PollingTuple tuple : pollingTuples) { - if (tuple.thread != null) { - // Interrupt and remove the thread - tuple.thread.interrupt(); - tuple.thread = null; - } - } - } - - // Remove the listener - listener = null; - - return false; - } - - private void populateExternalAddress(ComputerDetails details) { - boolean boundToNetwork = false; - boolean activeNetworkIsVpn = NetHelper.isActiveNetworkVpn(this); - ConnectivityManager connMgr = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE); - - // Check if we're currently connected to a VPN which may send our - // STUN request from an unexpected interface - if (activeNetworkIsVpn) { - // Acquire the default network lock since we could be changing global process state - defaultNetworkLock.lock(); - - // On Lollipop or later, we can bind our process to the underlying interface - // to ensure our STUN request goes out on that interface or not at all (which is - // preferable to getting a VPN endpoint address back). - Network[] networks = connMgr.getAllNetworks(); - for (Network net : networks) { - NetworkCapabilities netCaps = connMgr.getNetworkCapabilities(net); - if (netCaps != null) { - if (!netCaps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) && - !netCaps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) { - // This network looks like an underlying multicast-capable transport, - // so let's guess that it's probably where our mDNS response came from. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - if (connMgr.bindProcessToNetwork(net)) { - boundToNetwork = true; - break; - } - } else if (ConnectivityManager.setProcessDefaultNetwork(net)) { - boundToNetwork = true; - break; - } - } - } - } - - // Perform the STUN request if we're not on a VPN or if we bound to a network - if (!activeNetworkIsVpn || boundToNetwork) { - String stunResolvedAddress = NvConnection.findExternalAddressForMdns("stun.moonlight-stream.org", 3478); - if (stunResolvedAddress != null) { - // We don't know for sure what the external port is, so we will have to guess. - // When we contact the PC (if we haven't already), it will update the port. - details.remoteAddress = new ComputerDetails.AddressTuple(stunResolvedAddress, details.guessExternalPort()); - } - } - - // Unbind from the network - if (boundToNetwork) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - connMgr.bindProcessToNetwork(null); - } else { - ConnectivityManager.setProcessDefaultNetwork(null); - } - } - - // Unlock the network state - if (activeNetworkIsVpn) { - defaultNetworkLock.unlock(); - } - } - } - - private MdnsDiscoveryListener createDiscoveryListener() { - return new MdnsDiscoveryListener() { - @Override - public void notifyComputerAdded(MdnsComputer computer) { - ComputerDetails details = new ComputerDetails(); - - // Populate the computer template with mDNS info - if (computer.getLocalAddress() != null) { - details.localAddress = new ComputerDetails.AddressTuple(computer.getLocalAddress().getHostAddress(), computer.getPort()); - - // Since we're on the same network, we can use STUN to find - // our WAN address, which is also very likely the WAN address - // of the PC. We can use this later to connect remotely. - if (computer.getLocalAddress() instanceof Inet4Address) { - populateExternalAddress(details); - } - } - if (computer.getIpv6Address() != null) { - details.ipv6Address = new ComputerDetails.AddressTuple(computer.getIpv6Address().getHostAddress(), computer.getPort()); - } - - try { - // Kick off a blocking serverinfo poll on this machine - if (!addComputerBlocking(details)) { - LimeLog.warning("Auto-discovered PC failed to respond: "+details); - } - } catch (InterruptedException e) { - e.printStackTrace(); - - // InterruptedException clears the thread's interrupt status. Since we can't - // handle that here, we will re-interrupt the thread to set the interrupt - // status back to true. - Thread.currentThread().interrupt(); - } - } - - @Override - public void notifyDiscoveryFailure(Exception e) { - LimeLog.severe("mDNS discovery failed"); - e.printStackTrace(); - } - }; - } - - private void addTuple(ComputerDetails details) { - synchronized (pollingTuples) { - for (PollingTuple tuple : pollingTuples) { - // Check if this is the same computer - if (tuple.computer.uuid.equals(details.uuid)) { - // Update the saved computer with potentially new details - tuple.computer.update(details); - - // Start a polling thread if polling is active - if (pollingActive && tuple.thread == null) { - tuple.thread = createPollingThread(tuple); - tuple.thread.start(); - } - - // Found an entry so we're done - return; - } - } - - // If we got here, we didn't find an entry - PollingTuple tuple = new PollingTuple(details, null); - if (pollingActive) { - tuple.thread = createPollingThread(tuple); - } - pollingTuples.add(tuple); - if (tuple.thread != null) { - tuple.thread.start(); - } - } - } - - public boolean addComputerBlocking(ComputerDetails fakeDetails) throws InterruptedException { - // Block while we try to fill the details - - // We cannot use runPoll() here because it will attempt to persist the state of the machine - // in the database, which would be bad because we don't have our pinned cert loaded yet. - if (pollComputer(fakeDetails)) { - // See if we have record of this PC to pull its pinned cert - synchronized (pollingTuples) { - for (PollingTuple tuple : pollingTuples) { - if (tuple.computer.uuid.equals(fakeDetails.uuid)) { - fakeDetails.serverCert = tuple.computer.serverCert; - break; - } - } - } - - // Poll again, possibly with the pinned cert, to get accurate pairing information. - // This will insert the host into the database too. - runPoll(fakeDetails, true, 0); - } - - // If the machine is reachable, it was successful - if (fakeDetails.state == ComputerDetails.State.ONLINE) { - LimeLog.info("New PC ("+fakeDetails.name+") is UUID "+fakeDetails.uuid); - - // Start a polling thread for this machine - addTuple(fakeDetails); - return true; - } - else { - return false; - } - } - - public void removeComputer(ComputerDetails computer) { - if (!getLocalDatabaseReference()) { - return; - } - - // Remove it from the database - dbManager.deleteComputer(computer); - - synchronized (pollingTuples) { - // Remove the computer from the computer list - for (PollingTuple tuple : pollingTuples) { - if (tuple.computer.uuid.equals(computer.uuid)) { - if (tuple.thread != null) { - // Interrupt the thread on this entry - tuple.thread.interrupt(); - tuple.thread = null; - } - pollingTuples.remove(tuple); - break; - } - } - } - - releaseLocalDatabaseReference(); - } - - private boolean getLocalDatabaseReference() { - if (dbRefCount.get() == 0) { - return false; - } - - dbRefCount.incrementAndGet(); - return true; - } - - private void releaseLocalDatabaseReference() { - if (dbRefCount.decrementAndGet() == 0) { - dbManager.close(); - } - } - - private ComputerDetails tryPollIp(ComputerDetails details, ComputerDetails.AddressTuple address) { - try { - // If the current address's port number matches the active address's port number, we can also assume - // the HTTPS port will also match. This assumption is currently safe because Sunshine sets all ports - // as offsets from the base HTTP port and doesn't allow custom HttpsPort responses for WAN vs LAN. - boolean portMatchesActiveAddress = details.state == ComputerDetails.State.ONLINE && - details.activeAddress != null && address.port == details.activeAddress.port; - - NvHTTP http = new NvHTTP(address, portMatchesActiveAddress ? details.httpsPort : 0, idManager.getUniqueId(), details.serverCert, - PlatformBinding.getCryptoProvider(ComputerManagerService.this)); - - // If this PC is currently online at this address, extend the timeouts to allow more time for the PC to respond. - boolean isLikelyOnline = details.state == ComputerDetails.State.ONLINE && address.equals(details.activeAddress); - - ComputerDetails newDetails = http.getComputerDetails(isLikelyOnline); - - // Check if this is the PC we expected - if (newDetails.uuid == null) { - LimeLog.severe("Polling returned no UUID!"); - return null; - } - // details.uuid can be null on initial PC add - else if (details.uuid != null && !details.uuid.equals(newDetails.uuid)) { - // We got the wrong PC! - LimeLog.info("Polling returned the wrong PC!"); - return null; - } - - return newDetails; - } catch (XmlPullParserException e) { - e.printStackTrace(); - return null; - } catch (IOException e) { - return null; - } - } - - private static class ParallelPollTuple { - public ComputerDetails.AddressTuple address; - public ComputerDetails existingDetails; - - public boolean complete; - public Thread pollingThread; - public ComputerDetails returnedDetails; - - public ParallelPollTuple(ComputerDetails.AddressTuple address, ComputerDetails existingDetails) { - this.address = address; - this.existingDetails = existingDetails; - } - - public void interrupt() { - if (pollingThread != null) { - pollingThread.interrupt(); - } - } - } - - private void startParallelPollThread(ParallelPollTuple tuple, HashSet uniqueAddresses) { - // Don't bother starting a polling thread for an address that doesn't exist - // or if the address has already been polled with an earlier tuple - if (tuple.address == null || !uniqueAddresses.add(tuple.address)) { - tuple.complete = true; - tuple.returnedDetails = null; - return; - } - - tuple.pollingThread = new Thread() { - @Override - public void run() { - ComputerDetails details = tryPollIp(tuple.existingDetails, tuple.address); - - synchronized (tuple) { - tuple.complete = true; // Done - tuple.returnedDetails = details; // Polling result - - tuple.notify(); - } - } - }; - tuple.pollingThread.setName("Parallel Poll - "+tuple.address+" - "+tuple.existingDetails.name); - tuple.pollingThread.start(); - } - - private ComputerDetails parallelPollPc(ComputerDetails details) throws InterruptedException { - ParallelPollTuple localInfo = new ParallelPollTuple(details.localAddress, details); - ParallelPollTuple manualInfo = new ParallelPollTuple(details.manualAddress, details); - ParallelPollTuple remoteInfo = new ParallelPollTuple(details.remoteAddress, details); - ParallelPollTuple ipv6Info = new ParallelPollTuple(details.ipv6Address, details); - - // These must be started in order of precedence for the deduplication algorithm - // to result in the correct behavior. - HashSet uniqueAddresses = new HashSet<>(); - startParallelPollThread(localInfo, uniqueAddresses); - startParallelPollThread(manualInfo, uniqueAddresses); - startParallelPollThread(remoteInfo, uniqueAddresses); - startParallelPollThread(ipv6Info, uniqueAddresses); - - try { - // Check local first - synchronized (localInfo) { - while (!localInfo.complete) { - localInfo.wait(); - } - - if (localInfo.returnedDetails != null) { - localInfo.returnedDetails.activeAddress = localInfo.address; - return localInfo.returnedDetails; - } - } - - // Now manual - synchronized (manualInfo) { - while (!manualInfo.complete) { - manualInfo.wait(); - } - - if (manualInfo.returnedDetails != null) { - manualInfo.returnedDetails.activeAddress = manualInfo.address; - return manualInfo.returnedDetails; - } - } - - // Now remote IPv4 - synchronized (remoteInfo) { - while (!remoteInfo.complete) { - remoteInfo.wait(); - } - - if (remoteInfo.returnedDetails != null) { - remoteInfo.returnedDetails.activeAddress = remoteInfo.address; - return remoteInfo.returnedDetails; - } - } - - // Now global IPv6 - synchronized (ipv6Info) { - while (!ipv6Info.complete) { - ipv6Info.wait(); - } - - if (ipv6Info.returnedDetails != null) { - ipv6Info.returnedDetails.activeAddress = ipv6Info.address; - return ipv6Info.returnedDetails; - } - } - } finally { - // Stop any further polling if we've found a working address or we've been - // interrupted by an attempt to stop polling. - localInfo.interrupt(); - manualInfo.interrupt(); - remoteInfo.interrupt(); - ipv6Info.interrupt(); - } - - return null; - } - - private boolean pollComputer(ComputerDetails details) throws InterruptedException { - // Poll all addresses in parallel to speed up the process - LimeLog.info("Starting parallel poll for "+details.name+" ("+details.localAddress +", "+details.remoteAddress +", "+details.manualAddress+", "+details.ipv6Address+")"); - ComputerDetails polledDetails = parallelPollPc(details); - LimeLog.info("Parallel poll for "+details.name+" returned address: "+details.activeAddress); - - if (polledDetails != null) { - details.update(polledDetails); - return true; - } - else { - return false; - } - } - - @Override - public void onCreate() { - // Bind to the discovery service - bindService(new Intent(this, DiscoveryService.class), - discoveryServiceConnection, Service.BIND_AUTO_CREATE); - - // Lookup or generate this device's UID - idManager = new IdentityManager(this); - - // Initialize the DB - dbManager = new ComputerDatabaseManager(this); - dbRefCount.set(1); - - // Grab known machines into our computer list - if (!getLocalDatabaseReference()) { - return; - } - - for (ComputerDetails computer : dbManager.getAllComputers()) { - // Add tuples for each computer - addTuple(computer); - } - - releaseLocalDatabaseReference(); - - // Monitor for network changes to invalidate our PC state - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - networkCallback = new ConnectivityManager.NetworkCallback() { - @Override - public void onAvailable(Network network) { - LimeLog.info("Resetting PC state for new available network"); - synchronized (pollingTuples) { - for (PollingTuple tuple : pollingTuples) { - tuple.computer.state = ComputerDetails.State.UNKNOWN; - if (listener != null) { - listener.notifyComputerUpdated(tuple.computer); - } - } - } - } - - @Override - public void onLost(Network network) { - LimeLog.info("Offlining PCs due to network loss"); - synchronized (pollingTuples) { - for (PollingTuple tuple : pollingTuples) { - tuple.computer.state = ComputerDetails.State.OFFLINE; - if (listener != null) { - listener.notifyComputerUpdated(tuple.computer); - } - } - } - } - }; - - ConnectivityManager connMgr = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE); - connMgr.registerDefaultNetworkCallback(networkCallback); - } - } - - @Override - public void onDestroy() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - ConnectivityManager connMgr = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE); - connMgr.unregisterNetworkCallback(networkCallback); - } - - if (discoveryBinder != null) { - // Unbind from the discovery service - unbindService(discoveryServiceConnection); - } - - // FIXME: Should await termination here but we have timeout issues in HttpURLConnection - - // Remove the initial DB reference - releaseLocalDatabaseReference(); - } - - @Override - public IBinder onBind(Intent intent) { - return binder; - } - - public class ApplistPoller { - private Thread thread; - private final ComputerDetails computer; - private final Object pollEvent = new Object(); - private boolean receivedAppList = false; - - public ApplistPoller(ComputerDetails computer) { - this.computer = computer; - } - - public void pollNow() { - synchronized (pollEvent) { - pollEvent.notify(); - } - } - - private boolean waitPollingDelay() { - try { - synchronized (pollEvent) { - if (receivedAppList) { - // If we've already reported an app list successfully, - // wait the full polling period - pollEvent.wait(APPLIST_POLLING_PERIOD_MS); - } - else { - // If we've failed to get an app list so far, retry much earlier - pollEvent.wait(APPLIST_FAILED_POLLING_RETRY_MS); - } - } - } catch (InterruptedException e) { - return false; - } - - return thread != null && !thread.isInterrupted(); - } - - private PollingTuple getPollingTuple(ComputerDetails details) { - synchronized (pollingTuples) { - for (PollingTuple tuple : pollingTuples) { - if (details.uuid.equals(tuple.computer.uuid)) { - return tuple; - } - } - } - - return null; - } - - public void start() { - thread = new Thread() { - @Override - public void run() { - int emptyAppListResponses = 0; - do { - // Can't poll if it's not online or paired - if (computer.state != ComputerDetails.State.ONLINE || - computer.pairState != PairingManager.PairState.PAIRED) { - if (listener != null) { - listener.notifyComputerUpdated(computer); - } - continue; - } - - // Can't poll if there's no UUID yet - if (computer.uuid == null) { - continue; - } - - PollingTuple tuple = getPollingTuple(computer); - - try { - NvHTTP http = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(computer), computer.httpsPort, idManager.getUniqueId(), - computer.serverCert, PlatformBinding.getCryptoProvider(ComputerManagerService.this)); - - String appList; - if (tuple != null) { - // If we're polling this machine too, grab the network lock - // while doing the app list request to prevent other requests - // from being issued in the meantime. - synchronized (tuple.networkLock) { - appList = http.getAppListRaw(); - } - } - else { - // No polling is happening now, so we just call it directly - appList = http.getAppListRaw(); - } - - List list = NvHTTP.getAppListByReader(new StringReader(appList)); - if (list.isEmpty()) { - LimeLog.warning("Empty app list received from "+computer.uuid); - - // The app list might actually be empty, so if we get an empty response a few times - // in a row, we'll go ahead and believe it. - emptyAppListResponses++; - } - if (!appList.isEmpty() && - (!list.isEmpty() || emptyAppListResponses >= EMPTY_LIST_THRESHOLD)) { - // Open the cache file - try (final OutputStream cacheOut = CacheHelper.openCacheFileForOutput( - getCacheDir(), "applist", computer.uuid) - ) { - CacheHelper.writeStringToOutputStream(cacheOut, appList); - } catch (IOException e) { - e.printStackTrace(); - } - - // Reset empty count if it wasn't empty this time - if (!list.isEmpty()) { - emptyAppListResponses = 0; - } - - // Update the computer - computer.rawAppList = appList; - receivedAppList = true; - - // Notify that the app list has been updated - // and ensure that the thread is still active - if (listener != null && thread != null) { - listener.notifyComputerUpdated(computer); - } - } - else if (appList.isEmpty()) { - LimeLog.warning("Null app list received from "+computer.uuid); - } - } catch (IOException e) { - e.printStackTrace(); - } catch (XmlPullParserException e) { - e.printStackTrace(); - } - } while (waitPollingDelay()); - } - }; - thread.setName("App list polling thread for " + computer.name); - thread.start(); - } - - public void stop() { - if (thread != null) { - thread.interrupt(); - - // Don't join here because we might be blocked on network I/O - - thread = null; - } - } - } -} - -class PollingTuple { - public Thread thread; - public final ComputerDetails computer; - public final Object networkLock; - public long lastSuccessfulPollMs; - - public PollingTuple(ComputerDetails computer, Thread thread) { - this.computer = computer; - this.thread = thread; - this.networkLock = new Object(); - } -} - -class ReachabilityTuple { - public final String reachableAddress; - public final ComputerDetails computer; - - public ReachabilityTuple(ComputerDetails computer, String reachableAddress) { - this.computer = computer; - this.reachableAddress = reachableAddress; - } +package com.limelight.computers; + +import java.io.IOException; +import java.io.OutputStream; +import java.io.StringReader; +import java.net.Inet4Address; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +import com.limelight.LimeLog; +import com.limelight.binding.PlatformBinding; +import com.limelight.discovery.DiscoveryService; +import com.limelight.nvstream.NvConnection; +import com.limelight.nvstream.http.ComputerDetails; +import com.limelight.nvstream.http.NvApp; +import com.limelight.nvstream.http.NvHTTP; +import com.limelight.nvstream.http.PairingManager; +import com.limelight.nvstream.mdns.MdnsComputer; +import com.limelight.nvstream.mdns.MdnsDiscoveryListener; +import com.limelight.utils.CacheHelper; +import com.limelight.utils.NetHelper; +import com.limelight.utils.ServerHelper; + +import android.app.Service; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.net.ConnectivityManager; +import android.net.Network; +import android.net.NetworkCapabilities; +import android.os.Binder; +import android.os.Build; +import android.os.IBinder; +import android.os.SystemClock; + +import org.xmlpull.v1.XmlPullParserException; + +public class ComputerManagerService extends Service { + private static final int SERVERINFO_POLLING_PERIOD_MS = 1500; + private static final int APPLIST_POLLING_PERIOD_MS = 30000; + private static final int APPLIST_FAILED_POLLING_RETRY_MS = 2000; + private static final int MDNS_QUERY_PERIOD_MS = 1000; + private static final int OFFLINE_POLL_TRIES = 3; + private static final int INITIAL_POLL_TRIES = 2; + private static final int EMPTY_LIST_THRESHOLD = 3; + private static final int POLL_DATA_TTL_MS = 30000; + + private final ComputerManagerBinder binder = new ComputerManagerBinder(); + + private ComputerDatabaseManager dbManager; + private final AtomicInteger dbRefCount = new AtomicInteger(0); + + private IdentityManager idManager; + private final LinkedList pollingTuples = new LinkedList<>(); + private ComputerManagerListener listener = null; + private final AtomicInteger activePolls = new AtomicInteger(0); + private boolean pollingActive = false; + private final Lock defaultNetworkLock = new ReentrantLock(); + + private ConnectivityManager.NetworkCallback networkCallback; + + private DiscoveryService.DiscoveryBinder discoveryBinder; + private final ServiceConnection discoveryServiceConnection = new ServiceConnection() { + public void onServiceConnected(ComponentName className, IBinder binder) { + synchronized (discoveryServiceConnection) { + DiscoveryService.DiscoveryBinder privateBinder = ((DiscoveryService.DiscoveryBinder)binder); + + // Set us as the event listener + privateBinder.setListener(createDiscoveryListener()); + + // Signal a possible waiter that we're all setup + discoveryBinder = privateBinder; + discoveryServiceConnection.notifyAll(); + } + } + + public void onServiceDisconnected(ComponentName className) { + discoveryBinder = null; + } + }; + + // Returns true if the details object was modified + private boolean runPoll(ComputerDetails details, boolean newPc, int offlineCount) throws InterruptedException { + if (!getLocalDatabaseReference()) { + return false; + } + + final int pollTriesBeforeOffline = details.state == ComputerDetails.State.UNKNOWN ? + INITIAL_POLL_TRIES : OFFLINE_POLL_TRIES; + + activePolls.incrementAndGet(); + + // Poll the machine + try { + if (!pollComputer(details)) { + if (!newPc && offlineCount < pollTriesBeforeOffline) { + // Return without calling the listener + releaseLocalDatabaseReference(); + return false; + } + + details.state = ComputerDetails.State.OFFLINE; + } + } catch (InterruptedException e) { + releaseLocalDatabaseReference(); + throw e; + } finally { + activePolls.decrementAndGet(); + } + + // If it's online, update our persistent state + if (details.state == ComputerDetails.State.ONLINE) { + ComputerDetails existingComputer = dbManager.getComputerByUUID(details.uuid); + + // Check if it's in the database because it could have been + // removed after this was issued + if (!newPc && existingComputer == null) { + // It's gone + releaseLocalDatabaseReference(); + return false; + } + + // If we already have an entry for this computer in the DB, we must + // combine the existing data with this new data (which may be partially available + // due to detecting the PC via mDNS) without the saved external address. If we + // write to the DB without doing this first, we can overwrite our existing data. + if (existingComputer != null) { + existingComputer.update(details); + dbManager.updateComputer(existingComputer); + } + else { + try { + // If the active address is a site-local address (RFC 1918), + // then use STUN to populate the external address field if + // it's not set already. + if (details.remoteAddress == null) { + InetAddress addr = InetAddress.getByName(details.activeAddress.address); + if (addr.isSiteLocalAddress()) { + populateExternalAddress(details); + } + } + } catch (UnknownHostException ignored) {} + + dbManager.updateComputer(details); + } + } + + // Don't call the listener if this is a failed lookup of a new PC + if ((!newPc || details.state == ComputerDetails.State.ONLINE) && listener != null) { + listener.notifyComputerUpdated(details); + } + + releaseLocalDatabaseReference(); + return true; + } + + private Thread createPollingThread(final PollingTuple tuple) { + Thread t = new Thread() { + @Override + public void run() { + + int offlineCount = 0; + while (!isInterrupted() && pollingActive && tuple.thread == this) { + try { + // Only allow one request to the machine at a time + synchronized (tuple.networkLock) { + // Check if this poll has modified the details + if (!runPoll(tuple.computer, false, offlineCount)) { + LimeLog.warning(tuple.computer.name + " is offline (try " + offlineCount + ")"); + offlineCount++; + } else { + tuple.lastSuccessfulPollMs = SystemClock.elapsedRealtime(); + offlineCount = 0; + } + } + + // Wait until the next polling interval + Thread.sleep(SERVERINFO_POLLING_PERIOD_MS); + } catch (InterruptedException e) { + break; + } + } + } + }; + t.setName("Polling thread for " + tuple.computer.name); + return t; + } + + public class ComputerManagerBinder extends Binder { + public void startPolling(ComputerManagerListener listener) { + // Polling is active + pollingActive = true; + + // Set the listener + ComputerManagerService.this.listener = listener; + + // Start mDNS autodiscovery too + discoveryBinder.startDiscovery(MDNS_QUERY_PERIOD_MS); + + synchronized (pollingTuples) { + for (PollingTuple tuple : pollingTuples) { + // Enforce the poll data TTL + if (SystemClock.elapsedRealtime() - tuple.lastSuccessfulPollMs > POLL_DATA_TTL_MS) { + LimeLog.info("Timing out polled state for "+tuple.computer.name); + tuple.computer.state = ComputerDetails.State.UNKNOWN; + } + + // Report this computer initially + listener.notifyComputerUpdated(tuple.computer); + + // This polling thread might already be there + if (tuple.thread == null) { + tuple.thread = createPollingThread(tuple); + tuple.thread.start(); + } + } + } + } + + public void waitForReady() { + synchronized (discoveryServiceConnection) { + try { + while (discoveryBinder == null) { + // Wait for the bind notification + discoveryServiceConnection.wait(1000); + } + } catch (InterruptedException e) { + e.printStackTrace(); + + // InterruptedException clears the thread's interrupt status. Since we can't + // handle that here, we will re-interrupt the thread to set the interrupt + // status back to true. + Thread.currentThread().interrupt(); + } + } + } + + public void waitForPollingStopped() { + while (activePolls.get() != 0) { + try { + Thread.sleep(250); + } catch (InterruptedException e) { + e.printStackTrace(); + + // InterruptedException clears the thread's interrupt status. Since we can't + // handle that here, we will re-interrupt the thread to set the interrupt + // status back to true. + Thread.currentThread().interrupt(); + } + } + } + + public boolean addComputerBlocking(ComputerDetails fakeDetails) throws InterruptedException { + return ComputerManagerService.this.addComputerBlocking(fakeDetails); + } + + public void removeComputer(ComputerDetails computer) { + ComputerManagerService.this.removeComputer(computer); + } + + public void stopPolling() { + // Just call the unbind handler to cleanup + ComputerManagerService.this.onUnbind(null); + } + + public ApplistPoller createAppListPoller(ComputerDetails computer) { + return new ApplistPoller(computer); + } + + public String getUniqueId() { + return idManager.getUniqueId(); + } + + public ComputerDetails getComputer(String uuid) { + synchronized (pollingTuples) { + for (PollingTuple tuple : pollingTuples) { + if (uuid.equals(tuple.computer.uuid)) { + return tuple.computer; + } + } + } + + return null; + } + + public void invalidateStateForComputer(String uuid) { + synchronized (pollingTuples) { + for (PollingTuple tuple : pollingTuples) { + if (uuid.equals(tuple.computer.uuid)) { + // We need the network lock to prevent a concurrent poll + // from wiping this change out + synchronized (tuple.networkLock) { + tuple.computer.state = ComputerDetails.State.UNKNOWN; + } + } + } + } + } + } + + @Override + public boolean onUnbind(Intent intent) { + if (discoveryBinder != null) { + // Stop mDNS autodiscovery + discoveryBinder.stopDiscovery(); + } + + // Stop polling + pollingActive = false; + synchronized (pollingTuples) { + for (PollingTuple tuple : pollingTuples) { + if (tuple.thread != null) { + // Interrupt and remove the thread + tuple.thread.interrupt(); + tuple.thread = null; + } + } + } + + // Remove the listener + listener = null; + + return false; + } + + private void populateExternalAddress(ComputerDetails details) { + boolean boundToNetwork = false; + boolean activeNetworkIsVpn = NetHelper.isActiveNetworkVpn(this); + ConnectivityManager connMgr = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE); + + // Check if we're currently connected to a VPN which may send our + // STUN request from an unexpected interface + if (activeNetworkIsVpn) { + // Acquire the default network lock since we could be changing global process state + defaultNetworkLock.lock(); + + // On Lollipop or later, we can bind our process to the underlying interface + // to ensure our STUN request goes out on that interface or not at all (which is + // preferable to getting a VPN endpoint address back). + Network[] networks = connMgr.getAllNetworks(); + for (Network net : networks) { + NetworkCapabilities netCaps = connMgr.getNetworkCapabilities(net); + if (netCaps != null) { + if (!netCaps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) && + !netCaps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) { + // This network looks like an underlying multicast-capable transport, + // so let's guess that it's probably where our mDNS response came from. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + if (connMgr.bindProcessToNetwork(net)) { + boundToNetwork = true; + break; + } + } else if (ConnectivityManager.setProcessDefaultNetwork(net)) { + boundToNetwork = true; + break; + } + } + } + } + + // Perform the STUN request if we're not on a VPN or if we bound to a network + if (!activeNetworkIsVpn || boundToNetwork) { + String stunResolvedAddress = NvConnection.findExternalAddressForMdns("stun.moonlight-stream.org", 3478); + if (stunResolvedAddress != null) { + // We don't know for sure what the external port is, so we will have to guess. + // When we contact the PC (if we haven't already), it will update the port. + details.remoteAddress = new ComputerDetails.AddressTuple(stunResolvedAddress, details.guessExternalPort()); + } + } + + // Unbind from the network + if (boundToNetwork) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + connMgr.bindProcessToNetwork(null); + } else { + ConnectivityManager.setProcessDefaultNetwork(null); + } + } + + // Unlock the network state + if (activeNetworkIsVpn) { + defaultNetworkLock.unlock(); + } + } + } + + private MdnsDiscoveryListener createDiscoveryListener() { + return new MdnsDiscoveryListener() { + @Override + public void notifyComputerAdded(MdnsComputer computer) { + ComputerDetails details = new ComputerDetails(); + + // Populate the computer template with mDNS info + if (computer.getLocalAddress() != null) { + details.localAddress = new ComputerDetails.AddressTuple(computer.getLocalAddress().getHostAddress(), computer.getPort()); + + // Since we're on the same network, we can use STUN to find + // our WAN address, which is also very likely the WAN address + // of the PC. We can use this later to connect remotely. + if (computer.getLocalAddress() instanceof Inet4Address) { + populateExternalAddress(details); + } + } + if (computer.getIpv6Address() != null) { + details.ipv6Address = new ComputerDetails.AddressTuple(computer.getIpv6Address().getHostAddress(), computer.getPort()); + } + + try { + // Kick off a blocking serverinfo poll on this machine + if (!addComputerBlocking(details)) { + LimeLog.warning("Auto-discovered PC failed to respond: "+details); + } + } catch (InterruptedException e) { + e.printStackTrace(); + + // InterruptedException clears the thread's interrupt status. Since we can't + // handle that here, we will re-interrupt the thread to set the interrupt + // status back to true. + Thread.currentThread().interrupt(); + } + } + + @Override + public void notifyDiscoveryFailure(Exception e) { + LimeLog.severe("mDNS discovery failed"); + e.printStackTrace(); + } + }; + } + + private void addTuple(ComputerDetails details) { + synchronized (pollingTuples) { + for (PollingTuple tuple : pollingTuples) { + // Check if this is the same computer + if (tuple.computer.uuid.equals(details.uuid)) { + // Update the saved computer with potentially new details + tuple.computer.update(details); + + // Start a polling thread if polling is active + if (pollingActive && tuple.thread == null) { + tuple.thread = createPollingThread(tuple); + tuple.thread.start(); + } + + // Found an entry so we're done + return; + } + } + + // If we got here, we didn't find an entry + PollingTuple tuple = new PollingTuple(details, null); + if (pollingActive) { + tuple.thread = createPollingThread(tuple); + } + pollingTuples.add(tuple); + if (tuple.thread != null) { + tuple.thread.start(); + } + } + } + + public boolean addComputerBlocking(ComputerDetails fakeDetails) throws InterruptedException { + // Block while we try to fill the details + + // We cannot use runPoll() here because it will attempt to persist the state of the machine + // in the database, which would be bad because we don't have our pinned cert loaded yet. + if (pollComputer(fakeDetails)) { + // See if we have record of this PC to pull its pinned cert + synchronized (pollingTuples) { + for (PollingTuple tuple : pollingTuples) { + if (tuple.computer.uuid.equals(fakeDetails.uuid)) { + fakeDetails.serverCert = tuple.computer.serverCert; + break; + } + } + } + + // Poll again, possibly with the pinned cert, to get accurate pairing information. + // This will insert the host into the database too. + runPoll(fakeDetails, true, 0); + } + + // If the machine is reachable, it was successful + if (fakeDetails.state == ComputerDetails.State.ONLINE) { + LimeLog.info("New PC ("+fakeDetails.name+") is UUID "+fakeDetails.uuid); + + // Start a polling thread for this machine + addTuple(fakeDetails); + return true; + } + else { + return false; + } + } + + public void removeComputer(ComputerDetails computer) { + if (!getLocalDatabaseReference()) { + return; + } + + // Remove it from the database + dbManager.deleteComputer(computer); + + synchronized (pollingTuples) { + // Remove the computer from the computer list + for (PollingTuple tuple : pollingTuples) { + if (tuple.computer.uuid.equals(computer.uuid)) { + if (tuple.thread != null) { + // Interrupt the thread on this entry + tuple.thread.interrupt(); + tuple.thread = null; + } + pollingTuples.remove(tuple); + break; + } + } + } + + releaseLocalDatabaseReference(); + } + + private boolean getLocalDatabaseReference() { + if (dbRefCount.get() == 0) { + return false; + } + + dbRefCount.incrementAndGet(); + return true; + } + + private void releaseLocalDatabaseReference() { + if (dbRefCount.decrementAndGet() == 0) { + dbManager.close(); + } + } + + private ComputerDetails tryPollIp(ComputerDetails details, ComputerDetails.AddressTuple address) { + try { + // If the current address's port number matches the active address's port number, we can also assume + // the HTTPS port will also match. This assumption is currently safe because Sunshine sets all ports + // as offsets from the base HTTP port and doesn't allow custom HttpsPort responses for WAN vs LAN. + boolean portMatchesActiveAddress = details.state == ComputerDetails.State.ONLINE && + details.activeAddress != null && address.port == details.activeAddress.port; + + NvHTTP http = new NvHTTP(address, portMatchesActiveAddress ? details.httpsPort : 0, idManager.getUniqueId(), details.serverCert, + PlatformBinding.getCryptoProvider(ComputerManagerService.this)); + + // If this PC is currently online at this address, extend the timeouts to allow more time for the PC to respond. + boolean isLikelyOnline = details.state == ComputerDetails.State.ONLINE && address.equals(details.activeAddress); + + ComputerDetails newDetails = http.getComputerDetails(isLikelyOnline); + + // Check if this is the PC we expected + if (newDetails.uuid == null) { + LimeLog.severe("Polling returned no UUID!"); + return null; + } + // details.uuid can be null on initial PC add + else if (details.uuid != null && !details.uuid.equals(newDetails.uuid)) { + // We got the wrong PC! + LimeLog.info("Polling returned the wrong PC!"); + return null; + } + + return newDetails; + } catch (XmlPullParserException e) { + e.printStackTrace(); + return null; + } catch (IOException e) { + return null; + } + } + + private static class ParallelPollTuple { + public ComputerDetails.AddressTuple address; + public ComputerDetails existingDetails; + + public boolean complete; + public Thread pollingThread; + public ComputerDetails returnedDetails; + + public ParallelPollTuple(ComputerDetails.AddressTuple address, ComputerDetails existingDetails) { + this.address = address; + this.existingDetails = existingDetails; + } + + public void interrupt() { + if (pollingThread != null) { + pollingThread.interrupt(); + } + } + } + + private void startParallelPollThread(ParallelPollTuple tuple, HashSet uniqueAddresses) { + // Don't bother starting a polling thread for an address that doesn't exist + // or if the address has already been polled with an earlier tuple + if (tuple.address == null || !uniqueAddresses.add(tuple.address)) { + tuple.complete = true; + tuple.returnedDetails = null; + return; + } + + tuple.pollingThread = new Thread() { + @Override + public void run() { + ComputerDetails details = tryPollIp(tuple.existingDetails, tuple.address); + + synchronized (tuple) { + tuple.complete = true; // Done + tuple.returnedDetails = details; // Polling result + + tuple.notify(); + } + } + }; + tuple.pollingThread.setName("Parallel Poll - "+tuple.address+" - "+tuple.existingDetails.name); + tuple.pollingThread.start(); + } + + private ComputerDetails parallelPollPc(ComputerDetails details) throws InterruptedException { + ParallelPollTuple localInfo = new ParallelPollTuple(details.localAddress, details); + ParallelPollTuple manualInfo = new ParallelPollTuple(details.manualAddress, details); + ParallelPollTuple remoteInfo = new ParallelPollTuple(details.remoteAddress, details); + ParallelPollTuple ipv6Info = new ParallelPollTuple(details.ipv6Address, details); + + // These must be started in order of precedence for the deduplication algorithm + // to result in the correct behavior. + HashSet uniqueAddresses = new HashSet<>(); + startParallelPollThread(localInfo, uniqueAddresses); + startParallelPollThread(manualInfo, uniqueAddresses); + startParallelPollThread(remoteInfo, uniqueAddresses); + startParallelPollThread(ipv6Info, uniqueAddresses); + + try { + // Check local first + synchronized (localInfo) { + while (!localInfo.complete) { + localInfo.wait(); + } + + if (localInfo.returnedDetails != null) { + localInfo.returnedDetails.activeAddress = localInfo.address; + return localInfo.returnedDetails; + } + } + + // Now manual + synchronized (manualInfo) { + while (!manualInfo.complete) { + manualInfo.wait(); + } + + if (manualInfo.returnedDetails != null) { + manualInfo.returnedDetails.activeAddress = manualInfo.address; + return manualInfo.returnedDetails; + } + } + + // Now remote IPv4 + synchronized (remoteInfo) { + while (!remoteInfo.complete) { + remoteInfo.wait(); + } + + if (remoteInfo.returnedDetails != null) { + remoteInfo.returnedDetails.activeAddress = remoteInfo.address; + return remoteInfo.returnedDetails; + } + } + + // Now global IPv6 + synchronized (ipv6Info) { + while (!ipv6Info.complete) { + ipv6Info.wait(); + } + + if (ipv6Info.returnedDetails != null) { + ipv6Info.returnedDetails.activeAddress = ipv6Info.address; + return ipv6Info.returnedDetails; + } + } + } finally { + // Stop any further polling if we've found a working address or we've been + // interrupted by an attempt to stop polling. + localInfo.interrupt(); + manualInfo.interrupt(); + remoteInfo.interrupt(); + ipv6Info.interrupt(); + } + + return null; + } + + private boolean pollComputer(ComputerDetails details) throws InterruptedException { + // Poll all addresses in parallel to speed up the process + LimeLog.info("Starting parallel poll for "+details.name+" ("+details.localAddress +", "+details.remoteAddress +", "+details.manualAddress+", "+details.ipv6Address+")"); + ComputerDetails polledDetails = parallelPollPc(details); + LimeLog.info("Parallel poll for "+details.name+" returned address: "+details.activeAddress); + + if (polledDetails != null) { + details.update(polledDetails); + return true; + } + else { + return false; + } + } + + @Override + public void onCreate() { + // Bind to the discovery service + bindService(new Intent(this, DiscoveryService.class), + discoveryServiceConnection, Service.BIND_AUTO_CREATE); + + // Lookup or generate this device's UID + idManager = new IdentityManager(this); + + // Initialize the DB + dbManager = new ComputerDatabaseManager(this); + dbRefCount.set(1); + + // Grab known machines into our computer list + if (!getLocalDatabaseReference()) { + return; + } + + for (ComputerDetails computer : dbManager.getAllComputers()) { + // Add tuples for each computer + addTuple(computer); + } + + releaseLocalDatabaseReference(); + + // Monitor for network changes to invalidate our PC state + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + networkCallback = new ConnectivityManager.NetworkCallback() { + @Override + public void onAvailable(Network network) { + LimeLog.info("Resetting PC state for new available network"); + synchronized (pollingTuples) { + for (PollingTuple tuple : pollingTuples) { + tuple.computer.state = ComputerDetails.State.UNKNOWN; + if (listener != null) { + listener.notifyComputerUpdated(tuple.computer); + } + } + } + } + + @Override + public void onLost(Network network) { + LimeLog.info("Offlining PCs due to network loss"); + synchronized (pollingTuples) { + for (PollingTuple tuple : pollingTuples) { + tuple.computer.state = ComputerDetails.State.OFFLINE; + if (listener != null) { + listener.notifyComputerUpdated(tuple.computer); + } + } + } + } + }; + + ConnectivityManager connMgr = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE); + connMgr.registerDefaultNetworkCallback(networkCallback); + } + } + + @Override + public void onDestroy() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + ConnectivityManager connMgr = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE); + connMgr.unregisterNetworkCallback(networkCallback); + } + + if (discoveryBinder != null) { + // Unbind from the discovery service + unbindService(discoveryServiceConnection); + } + + // FIXME: Should await termination here but we have timeout issues in HttpURLConnection + + // Remove the initial DB reference + releaseLocalDatabaseReference(); + } + + @Override + public IBinder onBind(Intent intent) { + return binder; + } + + public class ApplistPoller { + private Thread thread; + private final ComputerDetails computer; + private final Object pollEvent = new Object(); + private boolean receivedAppList = false; + + public ApplistPoller(ComputerDetails computer) { + this.computer = computer; + } + + public void pollNow() { + synchronized (pollEvent) { + pollEvent.notify(); + } + } + + private boolean waitPollingDelay() { + try { + synchronized (pollEvent) { + if (receivedAppList) { + // If we've already reported an app list successfully, + // wait the full polling period + pollEvent.wait(APPLIST_POLLING_PERIOD_MS); + } + else { + // If we've failed to get an app list so far, retry much earlier + pollEvent.wait(APPLIST_FAILED_POLLING_RETRY_MS); + } + } + } catch (InterruptedException e) { + return false; + } + + return thread != null && !thread.isInterrupted(); + } + + private PollingTuple getPollingTuple(ComputerDetails details) { + synchronized (pollingTuples) { + for (PollingTuple tuple : pollingTuples) { + if (details.uuid.equals(tuple.computer.uuid)) { + return tuple; + } + } + } + + return null; + } + + public void start() { + thread = new Thread() { + @Override + public void run() { + int emptyAppListResponses = 0; + do { + // Can't poll if it's not online or paired + if (computer.state != ComputerDetails.State.ONLINE || + computer.pairState != PairingManager.PairState.PAIRED) { + if (listener != null) { + listener.notifyComputerUpdated(computer); + } + continue; + } + + // Can't poll if there's no UUID yet + if (computer.uuid == null) { + continue; + } + + PollingTuple tuple = getPollingTuple(computer); + + try { + NvHTTP http = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(computer), computer.httpsPort, idManager.getUniqueId(), + computer.serverCert, PlatformBinding.getCryptoProvider(ComputerManagerService.this)); + + String appList; + if (tuple != null) { + // If we're polling this machine too, grab the network lock + // while doing the app list request to prevent other requests + // from being issued in the meantime. + synchronized (tuple.networkLock) { + appList = http.getAppListRaw(); + } + } + else { + // No polling is happening now, so we just call it directly + appList = http.getAppListRaw(); + } + + List list = NvHTTP.getAppListByReader(new StringReader(appList)); + if (list.isEmpty()) { + LimeLog.warning("Empty app list received from "+computer.uuid); + + // The app list might actually be empty, so if we get an empty response a few times + // in a row, we'll go ahead and believe it. + emptyAppListResponses++; + } + if (!appList.isEmpty() && + (!list.isEmpty() || emptyAppListResponses >= EMPTY_LIST_THRESHOLD)) { + // Open the cache file + try (final OutputStream cacheOut = CacheHelper.openCacheFileForOutput( + getCacheDir(), "applist", computer.uuid) + ) { + CacheHelper.writeStringToOutputStream(cacheOut, appList); + } catch (IOException e) { + e.printStackTrace(); + } + + // Reset empty count if it wasn't empty this time + if (!list.isEmpty()) { + emptyAppListResponses = 0; + } + + // Update the computer + computer.rawAppList = appList; + receivedAppList = true; + + // Notify that the app list has been updated + // and ensure that the thread is still active + if (listener != null && thread != null) { + listener.notifyComputerUpdated(computer); + } + } + else if (appList.isEmpty()) { + LimeLog.warning("Null app list received from "+computer.uuid); + } + } catch (IOException e) { + e.printStackTrace(); + } catch (XmlPullParserException e) { + e.printStackTrace(); + } + } while (waitPollingDelay()); + } + }; + thread.setName("App list polling thread for " + computer.name); + thread.start(); + } + + public void stop() { + if (thread != null) { + thread.interrupt(); + + // Don't join here because we might be blocked on network I/O + + thread = null; + } + } + } +} + +class PollingTuple { + public Thread thread; + public final ComputerDetails computer; + public final Object networkLock; + public long lastSuccessfulPollMs; + + public PollingTuple(ComputerDetails computer, Thread thread) { + this.computer = computer; + this.thread = thread; + this.networkLock = new Object(); + } +} + +class ReachabilityTuple { + public final String reachableAddress; + public final ComputerDetails computer; + + public ReachabilityTuple(ComputerDetails computer, String reachableAddress) { + this.computer = computer; + this.reachableAddress = reachableAddress; + } } \ No newline at end of file diff --git a/app/src/main/java/com/limelight/computers/IdentityManager.java b/app/src/main/java/com/limelight/computers/IdentityManager.java old mode 100644 new mode 100755 index d0befc8bb3..cd0800ab04 --- a/app/src/main/java/com/limelight/computers/IdentityManager.java +++ b/app/src/main/java/com/limelight/computers/IdentityManager.java @@ -1,73 +1,73 @@ -package com.limelight.computers; - -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.InputStreamReader; -import java.io.OutputStreamWriter; -import java.util.Locale; -import java.util.Random; - -import com.limelight.LimeLog; - -import android.content.Context; - -public class IdentityManager { - private static final String UNIQUE_ID_FILE_NAME = "uniqueid"; - private static final int UID_SIZE_IN_BYTES = 8; - - private String uniqueId; - - public IdentityManager(Context c) { - uniqueId = loadUniqueId(c); - if (uniqueId == null) { - uniqueId = generateNewUniqueId(c); - } - - LimeLog.info("UID is now: "+uniqueId); - } - - public String getUniqueId() { - return uniqueId; - } - - private static String loadUniqueId(Context c) { - // 2 Hex digits per byte - char[] uid = new char[UID_SIZE_IN_BYTES * 2]; - LimeLog.info("Reading UID from disk"); - try (final InputStreamReader reader = - new InputStreamReader(c.openFileInput(UNIQUE_ID_FILE_NAME)) - ) { - if (reader.read(uid) != UID_SIZE_IN_BYTES * 2) { - LimeLog.severe("UID file data is truncated"); - return null; - } - return new String(uid); - } catch (FileNotFoundException e) { - LimeLog.info("No UID file found"); - return null; - } catch (IOException e) { - LimeLog.severe("Error while reading UID file"); - e.printStackTrace(); - return null; - } - } - - private static String generateNewUniqueId(Context c) { - // Generate a new UID hex string - LimeLog.info("Generating new UID"); - String uidStr = String.format((Locale)null, "%016x", new Random().nextLong()); - - try (final OutputStreamWriter writer = - new OutputStreamWriter(c.openFileOutput(UNIQUE_ID_FILE_NAME, 0)) - ) { - writer.write(uidStr); - LimeLog.info("UID written to disk"); - } catch (IOException e) { - LimeLog.severe("Error while writing UID file"); - e.printStackTrace(); - } - - // We can return a UID even if I/O fails - return uidStr; - } -} +package com.limelight.computers; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.util.Locale; +import java.util.Random; + +import com.limelight.LimeLog; + +import android.content.Context; + +public class IdentityManager { + private static final String UNIQUE_ID_FILE_NAME = "uniqueid"; + private static final int UID_SIZE_IN_BYTES = 8; + + private String uniqueId; + + public IdentityManager(Context c) { + uniqueId = loadUniqueId(c); + if (uniqueId == null) { + uniqueId = generateNewUniqueId(c); + } + + LimeLog.info("UID is now: "+uniqueId); + } + + public String getUniqueId() { + return uniqueId; + } + + private static String loadUniqueId(Context c) { + // 2 Hex digits per byte + char[] uid = new char[UID_SIZE_IN_BYTES * 2]; + LimeLog.info("Reading UID from disk"); + try (final InputStreamReader reader = + new InputStreamReader(c.openFileInput(UNIQUE_ID_FILE_NAME)) + ) { + if (reader.read(uid) != UID_SIZE_IN_BYTES * 2) { + LimeLog.severe("UID file data is truncated"); + return null; + } + return new String(uid); + } catch (FileNotFoundException e) { + LimeLog.info("No UID file found"); + return null; + } catch (IOException e) { + LimeLog.severe("Error while reading UID file"); + e.printStackTrace(); + return null; + } + } + + private static String generateNewUniqueId(Context c) { + // Generate a new UID hex string + LimeLog.info("Generating new UID"); + String uidStr = String.format((Locale)null, "%016x", new Random().nextLong()); + + try (final OutputStreamWriter writer = + new OutputStreamWriter(c.openFileOutput(UNIQUE_ID_FILE_NAME, 0)) + ) { + writer.write(uidStr); + LimeLog.info("UID written to disk"); + } catch (IOException e) { + LimeLog.severe("Error while writing UID file"); + e.printStackTrace(); + } + + // We can return a UID even if I/O fails + return uidStr; + } +} diff --git a/app/src/main/java/com/limelight/computers/LegacyDatabaseReader.java b/app/src/main/java/com/limelight/computers/LegacyDatabaseReader.java old mode 100644 new mode 100755 index 27a7f1a35a..61a0fd408f --- a/app/src/main/java/com/limelight/computers/LegacyDatabaseReader.java +++ b/app/src/main/java/com/limelight/computers/LegacyDatabaseReader.java @@ -1,103 +1,103 @@ -package com.limelight.computers; - -import android.content.Context; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; -import android.database.sqlite.SQLiteException; - -import com.limelight.LimeLog; -import com.limelight.nvstream.http.ComputerDetails; -import com.limelight.nvstream.http.NvHTTP; - -import java.net.InetAddress; -import java.net.UnknownHostException; -import java.util.LinkedList; -import java.util.List; - -public class LegacyDatabaseReader { - private static final String COMPUTER_DB_NAME = "computers.db"; - private static final String COMPUTER_TABLE_NAME = "Computers"; - - private static final String ADDRESS_PREFIX = "ADDRESS_PREFIX__"; - - private static ComputerDetails getComputerFromCursor(Cursor c) { - ComputerDetails details = new ComputerDetails(); - - details.name = c.getString(0); - details.uuid = c.getString(1); - - // An earlier schema defined addresses as byte blobs. We'll - // gracefully migrate those to strings so we can store DNS names - // too. To disambiguate, we'll need to prefix them with a string - // greater than the allowable IP address length. - try { - details.localAddress = new ComputerDetails.AddressTuple(InetAddress.getByAddress(c.getBlob(2)).getHostAddress(), NvHTTP.DEFAULT_HTTP_PORT); - LimeLog.warning("DB: Legacy local address for " + details.name); - } catch (UnknownHostException e) { - // This is probably a hostname/address with the prefix string - String stringData = c.getString(2); - if (stringData.startsWith(ADDRESS_PREFIX)) { - details.localAddress = new ComputerDetails.AddressTuple(c.getString(2).substring(ADDRESS_PREFIX.length()), NvHTTP.DEFAULT_HTTP_PORT); - } else { - LimeLog.severe("DB: Corrupted local address for " + details.name); - } - } - - try { - details.remoteAddress = new ComputerDetails.AddressTuple(InetAddress.getByAddress(c.getBlob(3)).getHostAddress(), NvHTTP.DEFAULT_HTTP_PORT); - LimeLog.warning("DB: Legacy remote address for " + details.name); - } catch (UnknownHostException e) { - // This is probably a hostname/address with the prefix string - String stringData = c.getString(3); - if (stringData.startsWith(ADDRESS_PREFIX)) { - details.remoteAddress = new ComputerDetails.AddressTuple(c.getString(3).substring(ADDRESS_PREFIX.length()), NvHTTP.DEFAULT_HTTP_PORT); - } else { - LimeLog.severe("DB: Corrupted remote address for " + details.name); - } - } - - // On older versions of Moonlight, this is typically where manual addresses got stored, - // so let's initialize it just to be safe. - details.manualAddress = details.remoteAddress; - - details.macAddress = c.getString(4); - - // This signifies we don't have dynamic state (like pair state) - details.state = ComputerDetails.State.UNKNOWN; - - return details; - } - - private static List getAllComputers(SQLiteDatabase db) { - try (final Cursor c = db.rawQuery("SELECT * FROM " + COMPUTER_TABLE_NAME, null)) { - LinkedList computerList = new LinkedList<>(); - while (c.moveToNext()) { - ComputerDetails details = getComputerFromCursor(c); - - // If a critical field is corrupt or missing, skip the database entry - if (details.uuid == null) { - continue; - } - - computerList.add(details); - } - - return computerList; - } - } - - public static List migrateAllComputers(Context c) { - try (final SQLiteDatabase computerDb = SQLiteDatabase.openDatabase( - c.getDatabasePath(COMPUTER_DB_NAME).getPath(), - null, SQLiteDatabase.OPEN_READONLY) - ) { - // Open the existing database - return getAllComputers(computerDb); - } catch (SQLiteException e) { - return new LinkedList(); - } finally { - // Close and delete the old DB - c.deleteDatabase(COMPUTER_DB_NAME); - } - } +package com.limelight.computers; + +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteException; + +import com.limelight.LimeLog; +import com.limelight.nvstream.http.ComputerDetails; +import com.limelight.nvstream.http.NvHTTP; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.LinkedList; +import java.util.List; + +public class LegacyDatabaseReader { + private static final String COMPUTER_DB_NAME = "computers.db"; + private static final String COMPUTER_TABLE_NAME = "Computers"; + + private static final String ADDRESS_PREFIX = "ADDRESS_PREFIX__"; + + private static ComputerDetails getComputerFromCursor(Cursor c) { + ComputerDetails details = new ComputerDetails(); + + details.name = c.getString(0); + details.uuid = c.getString(1); + + // An earlier schema defined addresses as byte blobs. We'll + // gracefully migrate those to strings so we can store DNS names + // too. To disambiguate, we'll need to prefix them with a string + // greater than the allowable IP address length. + try { + details.localAddress = new ComputerDetails.AddressTuple(InetAddress.getByAddress(c.getBlob(2)).getHostAddress(), NvHTTP.DEFAULT_HTTP_PORT); + LimeLog.warning("DB: Legacy local address for " + details.name); + } catch (UnknownHostException e) { + // This is probably a hostname/address with the prefix string + String stringData = c.getString(2); + if (stringData.startsWith(ADDRESS_PREFIX)) { + details.localAddress = new ComputerDetails.AddressTuple(c.getString(2).substring(ADDRESS_PREFIX.length()), NvHTTP.DEFAULT_HTTP_PORT); + } else { + LimeLog.severe("DB: Corrupted local address for " + details.name); + } + } + + try { + details.remoteAddress = new ComputerDetails.AddressTuple(InetAddress.getByAddress(c.getBlob(3)).getHostAddress(), NvHTTP.DEFAULT_HTTP_PORT); + LimeLog.warning("DB: Legacy remote address for " + details.name); + } catch (UnknownHostException e) { + // This is probably a hostname/address with the prefix string + String stringData = c.getString(3); + if (stringData.startsWith(ADDRESS_PREFIX)) { + details.remoteAddress = new ComputerDetails.AddressTuple(c.getString(3).substring(ADDRESS_PREFIX.length()), NvHTTP.DEFAULT_HTTP_PORT); + } else { + LimeLog.severe("DB: Corrupted remote address for " + details.name); + } + } + + // On older versions of Moonlight, this is typically where manual addresses got stored, + // so let's initialize it just to be safe. + details.manualAddress = details.remoteAddress; + + details.macAddress = c.getString(4); + + // This signifies we don't have dynamic state (like pair state) + details.state = ComputerDetails.State.UNKNOWN; + + return details; + } + + private static List getAllComputers(SQLiteDatabase db) { + try (final Cursor c = db.rawQuery("SELECT * FROM " + COMPUTER_TABLE_NAME, null)) { + LinkedList computerList = new LinkedList<>(); + while (c.moveToNext()) { + ComputerDetails details = getComputerFromCursor(c); + + // If a critical field is corrupt or missing, skip the database entry + if (details.uuid == null) { + continue; + } + + computerList.add(details); + } + + return computerList; + } + } + + public static List migrateAllComputers(Context c) { + try (final SQLiteDatabase computerDb = SQLiteDatabase.openDatabase( + c.getDatabasePath(COMPUTER_DB_NAME).getPath(), + null, SQLiteDatabase.OPEN_READONLY) + ) { + // Open the existing database + return getAllComputers(computerDb); + } catch (SQLiteException e) { + return new LinkedList(); + } finally { + // Close and delete the old DB + c.deleteDatabase(COMPUTER_DB_NAME); + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/limelight/computers/LegacyDatabaseReader2.java b/app/src/main/java/com/limelight/computers/LegacyDatabaseReader2.java old mode 100644 new mode 100755 index 55ad59a4a8..7791bda2b1 --- a/app/src/main/java/com/limelight/computers/LegacyDatabaseReader2.java +++ b/app/src/main/java/com/limelight/computers/LegacyDatabaseReader2.java @@ -1,84 +1,84 @@ -package com.limelight.computers; - -import android.content.Context; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; -import android.database.sqlite.SQLiteException; - -import com.limelight.nvstream.http.ComputerDetails; -import com.limelight.nvstream.http.NvHTTP; - -import java.io.ByteArrayInputStream; -import java.security.cert.CertificateException; -import java.security.cert.CertificateFactory; -import java.security.cert.X509Certificate; -import java.util.LinkedList; -import java.util.List; - -public class LegacyDatabaseReader2 { - private static final String COMPUTER_DB_NAME = "computers2.db"; - private static final String COMPUTER_TABLE_NAME = "Computers"; - - private static ComputerDetails getComputerFromCursor(Cursor c) { - ComputerDetails details = new ComputerDetails(); - - details.uuid = c.getString(0); - details.name = c.getString(1); - details.localAddress = new ComputerDetails.AddressTuple(c.getString(2), NvHTTP.DEFAULT_HTTP_PORT); - details.remoteAddress = new ComputerDetails.AddressTuple(c.getString(3), NvHTTP.DEFAULT_HTTP_PORT); - details.manualAddress = new ComputerDetails.AddressTuple(c.getString(4), NvHTTP.DEFAULT_HTTP_PORT); - details.macAddress = c.getString(5); - - // This column wasn't always present in the old schema - if (c.getColumnCount() >= 7) { - try { - byte[] derCertData = c.getBlob(6); - - if (derCertData != null) { - details.serverCert = (X509Certificate) CertificateFactory.getInstance("X.509") - .generateCertificate(new ByteArrayInputStream(derCertData)); - } - } catch (CertificateException e) { - e.printStackTrace(); - } - } - - // This signifies we don't have dynamic state (like pair state) - details.state = ComputerDetails.State.UNKNOWN; - - return details; - } - - public static List getAllComputers(SQLiteDatabase computerDb) { - try (final Cursor c = computerDb.rawQuery("SELECT * FROM "+COMPUTER_TABLE_NAME, null)) { - LinkedList computerList = new LinkedList<>(); - while (c.moveToNext()) { - ComputerDetails details = getComputerFromCursor(c); - - // If a critical field is corrupt or missing, skip the database entry - if (details.uuid == null) { - continue; - } - - computerList.add(details); - } - - return computerList; - } - } - - public static List migrateAllComputers(Context c) { - try (final SQLiteDatabase computerDb = SQLiteDatabase.openDatabase( - c.getDatabasePath(COMPUTER_DB_NAME).getPath(), - null, SQLiteDatabase.OPEN_READONLY) - ) { - // Open the existing database - return getAllComputers(computerDb); - } catch (SQLiteException e) { - return new LinkedList(); - } finally { - // Close and delete the old DB - c.deleteDatabase(COMPUTER_DB_NAME); - } - } +package com.limelight.computers; + +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteException; + +import com.limelight.nvstream.http.ComputerDetails; +import com.limelight.nvstream.http.NvHTTP; + +import java.io.ByteArrayInputStream; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.LinkedList; +import java.util.List; + +public class LegacyDatabaseReader2 { + private static final String COMPUTER_DB_NAME = "computers2.db"; + private static final String COMPUTER_TABLE_NAME = "Computers"; + + private static ComputerDetails getComputerFromCursor(Cursor c) { + ComputerDetails details = new ComputerDetails(); + + details.uuid = c.getString(0); + details.name = c.getString(1); + details.localAddress = new ComputerDetails.AddressTuple(c.getString(2), NvHTTP.DEFAULT_HTTP_PORT); + details.remoteAddress = new ComputerDetails.AddressTuple(c.getString(3), NvHTTP.DEFAULT_HTTP_PORT); + details.manualAddress = new ComputerDetails.AddressTuple(c.getString(4), NvHTTP.DEFAULT_HTTP_PORT); + details.macAddress = c.getString(5); + + // This column wasn't always present in the old schema + if (c.getColumnCount() >= 7) { + try { + byte[] derCertData = c.getBlob(6); + + if (derCertData != null) { + details.serverCert = (X509Certificate) CertificateFactory.getInstance("X.509") + .generateCertificate(new ByteArrayInputStream(derCertData)); + } + } catch (CertificateException e) { + e.printStackTrace(); + } + } + + // This signifies we don't have dynamic state (like pair state) + details.state = ComputerDetails.State.UNKNOWN; + + return details; + } + + public static List getAllComputers(SQLiteDatabase computerDb) { + try (final Cursor c = computerDb.rawQuery("SELECT * FROM "+COMPUTER_TABLE_NAME, null)) { + LinkedList computerList = new LinkedList<>(); + while (c.moveToNext()) { + ComputerDetails details = getComputerFromCursor(c); + + // If a critical field is corrupt or missing, skip the database entry + if (details.uuid == null) { + continue; + } + + computerList.add(details); + } + + return computerList; + } + } + + public static List migrateAllComputers(Context c) { + try (final SQLiteDatabase computerDb = SQLiteDatabase.openDatabase( + c.getDatabasePath(COMPUTER_DB_NAME).getPath(), + null, SQLiteDatabase.OPEN_READONLY) + ) { + // Open the existing database + return getAllComputers(computerDb); + } catch (SQLiteException e) { + return new LinkedList(); + } finally { + // Close and delete the old DB + c.deleteDatabase(COMPUTER_DB_NAME); + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/limelight/computers/LegacyDatabaseReader3.java b/app/src/main/java/com/limelight/computers/LegacyDatabaseReader3.java old mode 100644 new mode 100755 index aca6d1e1c7..cdf779ef0b --- a/app/src/main/java/com/limelight/computers/LegacyDatabaseReader3.java +++ b/app/src/main/java/com/limelight/computers/LegacyDatabaseReader3.java @@ -1,123 +1,123 @@ -package com.limelight.computers; - -import android.content.Context; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; -import android.database.sqlite.SQLiteException; - -import com.limelight.nvstream.http.ComputerDetails; -import com.limelight.nvstream.http.NvHTTP; - -import java.io.ByteArrayInputStream; -import java.security.cert.CertificateException; -import java.security.cert.CertificateFactory; -import java.security.cert.X509Certificate; -import java.util.LinkedList; -import java.util.List; - -public class LegacyDatabaseReader3 { - private static final String COMPUTER_DB_NAME = "computers3.db"; - private static final String COMPUTER_TABLE_NAME = "Computers"; - - private static final char ADDRESS_DELIMITER = ';'; - private static final char PORT_DELIMITER = '_'; - - private static String readNonEmptyString(String input) { - if (input.isEmpty()) { - return null; - } - - return input; - } - - private static ComputerDetails.AddressTuple splitAddressToTuple(String input) { - if (input == null) { - return null; - } - - String[] parts = input.split(""+PORT_DELIMITER, -1); - if (parts.length == 1) { - return new ComputerDetails.AddressTuple(parts[0], NvHTTP.DEFAULT_HTTP_PORT); - } - else { - return new ComputerDetails.AddressTuple(parts[0], Integer.parseInt(parts[1])); - } - } - - private static String splitTupleToAddress(ComputerDetails.AddressTuple tuple) { - return tuple.address+PORT_DELIMITER+tuple.port; - } - - private static ComputerDetails getComputerFromCursor(Cursor c) { - ComputerDetails details = new ComputerDetails(); - - details.uuid = c.getString(0); - details.name = c.getString(1); - - String[] addresses = c.getString(2).split(""+ADDRESS_DELIMITER, -1); - - details.localAddress = splitAddressToTuple(readNonEmptyString(addresses[0])); - details.remoteAddress = splitAddressToTuple(readNonEmptyString(addresses[1])); - details.manualAddress = splitAddressToTuple(readNonEmptyString(addresses[2])); - details.ipv6Address = splitAddressToTuple(readNonEmptyString(addresses[3])); - - // External port is persisted in the remote address field - if (details.remoteAddress != null) { - details.externalPort = details.remoteAddress.port; - } - else { - details.externalPort = NvHTTP.DEFAULT_HTTP_PORT; - } - - details.macAddress = c.getString(3); - - try { - byte[] derCertData = c.getBlob(4); - - if (derCertData != null) { - details.serverCert = (X509Certificate) CertificateFactory.getInstance("X.509") - .generateCertificate(new ByteArrayInputStream(derCertData)); - } - } catch (CertificateException e) { - e.printStackTrace(); - } - - // This signifies we don't have dynamic state (like pair state) - details.state = ComputerDetails.State.UNKNOWN; - - return details; - } - - public static List getAllComputers(SQLiteDatabase computerDb) { - try (final Cursor c = computerDb.rawQuery("SELECT * FROM "+COMPUTER_TABLE_NAME, null)) { - LinkedList computerList = new LinkedList<>(); - while (c.moveToNext()) { - ComputerDetails details = getComputerFromCursor(c); - - // If a critical field is corrupt or missing, skip the database entry - if (details.uuid == null) { - continue; - } - - computerList.add(details); - } - - return computerList; - } - } - - public static List migrateAllComputers(Context c) { - try (final SQLiteDatabase computerDb = SQLiteDatabase.openDatabase( - c.getDatabasePath(COMPUTER_DB_NAME).getPath(), - null, SQLiteDatabase.OPEN_READONLY) - ) { - // Open the existing database - return getAllComputers(computerDb); - } catch (SQLiteException e) { - return new LinkedList(); - } finally { - // Close and delete the old DB - c.deleteDatabase(COMPUTER_DB_NAME); - } - } -} +package com.limelight.computers; + +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteException; + +import com.limelight.nvstream.http.ComputerDetails; +import com.limelight.nvstream.http.NvHTTP; + +import java.io.ByteArrayInputStream; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.LinkedList; +import java.util.List; + +public class LegacyDatabaseReader3 { + private static final String COMPUTER_DB_NAME = "computers3.db"; + private static final String COMPUTER_TABLE_NAME = "Computers"; + + private static final char ADDRESS_DELIMITER = ';'; + private static final char PORT_DELIMITER = '_'; + + private static String readNonEmptyString(String input) { + if (input.isEmpty()) { + return null; + } + + return input; + } + + private static ComputerDetails.AddressTuple splitAddressToTuple(String input) { + if (input == null) { + return null; + } + + String[] parts = input.split(""+PORT_DELIMITER, -1); + if (parts.length == 1) { + return new ComputerDetails.AddressTuple(parts[0], NvHTTP.DEFAULT_HTTP_PORT); + } + else { + return new ComputerDetails.AddressTuple(parts[0], Integer.parseInt(parts[1])); + } + } + + private static String splitTupleToAddress(ComputerDetails.AddressTuple tuple) { + return tuple.address+PORT_DELIMITER+tuple.port; + } + + private static ComputerDetails getComputerFromCursor(Cursor c) { + ComputerDetails details = new ComputerDetails(); + + details.uuid = c.getString(0); + details.name = c.getString(1); + + String[] addresses = c.getString(2).split(""+ADDRESS_DELIMITER, -1); + + details.localAddress = splitAddressToTuple(readNonEmptyString(addresses[0])); + details.remoteAddress = splitAddressToTuple(readNonEmptyString(addresses[1])); + details.manualAddress = splitAddressToTuple(readNonEmptyString(addresses[2])); + details.ipv6Address = splitAddressToTuple(readNonEmptyString(addresses[3])); + + // External port is persisted in the remote address field + if (details.remoteAddress != null) { + details.externalPort = details.remoteAddress.port; + } + else { + details.externalPort = NvHTTP.DEFAULT_HTTP_PORT; + } + + details.macAddress = c.getString(3); + + try { + byte[] derCertData = c.getBlob(4); + + if (derCertData != null) { + details.serverCert = (X509Certificate) CertificateFactory.getInstance("X.509") + .generateCertificate(new ByteArrayInputStream(derCertData)); + } + } catch (CertificateException e) { + e.printStackTrace(); + } + + // This signifies we don't have dynamic state (like pair state) + details.state = ComputerDetails.State.UNKNOWN; + + return details; + } + + public static List getAllComputers(SQLiteDatabase computerDb) { + try (final Cursor c = computerDb.rawQuery("SELECT * FROM "+COMPUTER_TABLE_NAME, null)) { + LinkedList computerList = new LinkedList<>(); + while (c.moveToNext()) { + ComputerDetails details = getComputerFromCursor(c); + + // If a critical field is corrupt or missing, skip the database entry + if (details.uuid == null) { + continue; + } + + computerList.add(details); + } + + return computerList; + } + } + + public static List migrateAllComputers(Context c) { + try (final SQLiteDatabase computerDb = SQLiteDatabase.openDatabase( + c.getDatabasePath(COMPUTER_DB_NAME).getPath(), + null, SQLiteDatabase.OPEN_READONLY) + ) { + // Open the existing database + return getAllComputers(computerDb); + } catch (SQLiteException e) { + return new LinkedList(); + } finally { + // Close and delete the old DB + c.deleteDatabase(COMPUTER_DB_NAME); + } + } +} diff --git a/app/src/main/java/com/limelight/discovery/DiscoveryService.java b/app/src/main/java/com/limelight/discovery/DiscoveryService.java old mode 100644 new mode 100755 index f8c855d679..4a9fcd5e86 --- a/app/src/main/java/com/limelight/discovery/DiscoveryService.java +++ b/app/src/main/java/com/limelight/discovery/DiscoveryService.java @@ -1,90 +1,90 @@ -package com.limelight.discovery; - -import java.util.List; - -import com.limelight.nvstream.mdns.MdnsComputer; -import com.limelight.nvstream.mdns.JmDNSDiscoveryAgent; -import com.limelight.nvstream.mdns.MdnsDiscoveryAgent; -import com.limelight.nvstream.mdns.MdnsDiscoveryListener; -import com.limelight.nvstream.mdns.NsdManagerDiscoveryAgent; - -import android.app.Service; -import android.content.Intent; -import android.os.Binder; -import android.os.Build; -import android.os.IBinder; - -public class DiscoveryService extends Service { - - private MdnsDiscoveryAgent discoveryAgent; - private MdnsDiscoveryListener boundListener; - - public class DiscoveryBinder extends Binder { - public void setListener(MdnsDiscoveryListener listener) { - boundListener = listener; - } - - public void startDiscovery(int queryIntervalMs) { - discoveryAgent.startDiscovery(queryIntervalMs); - } - - public void stopDiscovery() { - discoveryAgent.stopDiscovery(); - } - - public List getComputerSet() { - return discoveryAgent.getComputerSet(); - } - } - - @Override - public void onCreate() { - MdnsDiscoveryListener listener = new MdnsDiscoveryListener() { - @Override - public void notifyComputerAdded(MdnsComputer computer) { - if (boundListener != null) { - boundListener.notifyComputerAdded(computer); - } - } - - @Override - public void notifyDiscoveryFailure(Exception e) { - if (boundListener != null) { - boundListener.notifyDiscoveryFailure(e); - } - } - }; - - // Prior to Android 14, NsdManager doesn't provide all the capabilities needed for parity - // with jmDNS (specifically handling multiple addresses for a single service). There are - // also documented reliability bugs early in the Android 4.x series shortly after it was - // introduced. The benefit of using NsdManager over jmDNS is that it works correctly in - // environments where mDNS proxying is required, like ChromeOS, WSA, and the emulator. - // - // As such, we use the jmDNS-based MdnsDiscoveryAgent prior to Android 14 and NsdManager - // on Android 14 and above. - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - discoveryAgent = new JmDNSDiscoveryAgent(getApplicationContext(), listener); - } - else { - discoveryAgent = new NsdManagerDiscoveryAgent(getApplicationContext(), listener); - } - } - - private final DiscoveryBinder binder = new DiscoveryBinder(); - - @Override - public IBinder onBind(Intent intent) { - return binder; - } - - @Override - public boolean onUnbind(Intent intent) { - // Stop any discovery session - discoveryAgent.stopDiscovery(); - - // Unbind the listener - boundListener = null; - return false; - } -} +package com.limelight.discovery; + +import java.util.List; + +import com.limelight.nvstream.mdns.MdnsComputer; +import com.limelight.nvstream.mdns.JmDNSDiscoveryAgent; +import com.limelight.nvstream.mdns.MdnsDiscoveryAgent; +import com.limelight.nvstream.mdns.MdnsDiscoveryListener; +import com.limelight.nvstream.mdns.NsdManagerDiscoveryAgent; + +import android.app.Service; +import android.content.Intent; +import android.os.Binder; +import android.os.Build; +import android.os.IBinder; + +public class DiscoveryService extends Service { + + private MdnsDiscoveryAgent discoveryAgent; + private MdnsDiscoveryListener boundListener; + + public class DiscoveryBinder extends Binder { + public void setListener(MdnsDiscoveryListener listener) { + boundListener = listener; + } + + public void startDiscovery(int queryIntervalMs) { + discoveryAgent.startDiscovery(queryIntervalMs); + } + + public void stopDiscovery() { + discoveryAgent.stopDiscovery(); + } + + public List getComputerSet() { + return discoveryAgent.getComputerSet(); + } + } + + @Override + public void onCreate() { + MdnsDiscoveryListener listener = new MdnsDiscoveryListener() { + @Override + public void notifyComputerAdded(MdnsComputer computer) { + if (boundListener != null) { + boundListener.notifyComputerAdded(computer); + } + } + + @Override + public void notifyDiscoveryFailure(Exception e) { + if (boundListener != null) { + boundListener.notifyDiscoveryFailure(e); + } + } + }; + + // Prior to Android 14, NsdManager doesn't provide all the capabilities needed for parity + // with jmDNS (specifically handling multiple addresses for a single service). There are + // also documented reliability bugs early in the Android 4.x series shortly after it was + // introduced. The benefit of using NsdManager over jmDNS is that it works correctly in + // environments where mDNS proxying is required, like ChromeOS, WSA, and the emulator. + // + // As such, we use the jmDNS-based MdnsDiscoveryAgent prior to Android 14 and NsdManager + // on Android 14 and above. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + discoveryAgent = new JmDNSDiscoveryAgent(getApplicationContext(), listener); + } + else { + discoveryAgent = new NsdManagerDiscoveryAgent(getApplicationContext(), listener); + } + } + + private final DiscoveryBinder binder = new DiscoveryBinder(); + + @Override + public IBinder onBind(Intent intent) { + return binder; + } + + @Override + public boolean onUnbind(Intent intent) { + // Stop any discovery session + discoveryAgent.stopDiscovery(); + + // Unbind the listener + boundListener = null; + return false; + } +} diff --git a/app/src/main/java/com/limelight/grid/AppGridAdapter.java b/app/src/main/java/com/limelight/grid/AppGridAdapter.java old mode 100644 new mode 100755 index de594bba74..eb37289d95 --- a/app/src/main/java/com/limelight/grid/AppGridAdapter.java +++ b/app/src/main/java/com/limelight/grid/AppGridAdapter.java @@ -1,184 +1,184 @@ -package com.limelight.grid; - -import android.content.Context; -import android.graphics.BitmapFactory; -import android.view.View; -import android.widget.ImageView; -import android.widget.ProgressBar; -import android.widget.TextView; - -import com.limelight.AppView; -import com.limelight.LimeLog; -import com.limelight.R; -import com.limelight.grid.assets.CachedAppAssetLoader; -import com.limelight.grid.assets.DiskAssetLoader; -import com.limelight.grid.assets.MemoryAssetLoader; -import com.limelight.grid.assets.NetworkAssetLoader; -import com.limelight.nvstream.http.ComputerDetails; -import com.limelight.preferences.PreferenceConfiguration; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.Comparator; -import java.util.HashSet; -import java.util.List; -import java.util.Set; - -@SuppressWarnings("unchecked") -public class AppGridAdapter extends GenericGridAdapter { - private static final int ART_WIDTH_PX = 300; - private static final int SMALL_WIDTH_DP = 100; - private static final int LARGE_WIDTH_DP = 150; - - private final ComputerDetails computer; - private final String uniqueId; - private final boolean showHiddenApps; - - private CachedAppAssetLoader loader; - private Set hiddenAppIds = new HashSet<>(); - private ArrayList allApps = new ArrayList<>(); - - public AppGridAdapter(Context context, PreferenceConfiguration prefs, ComputerDetails computer, String uniqueId, boolean showHiddenApps) { - super(context, getLayoutIdForPreferences(prefs)); - - this.computer = computer; - this.uniqueId = uniqueId; - this.showHiddenApps = showHiddenApps; - - updateLayoutWithPreferences(context, prefs); - } - - public void updateHiddenApps(Set newHiddenAppIds, boolean hideImmediately) { - this.hiddenAppIds.clear(); - this.hiddenAppIds.addAll(newHiddenAppIds); - - if (hideImmediately) { - // Reconstruct the itemList with the new hidden app set - itemList.clear(); - for (AppView.AppObject app : allApps) { - app.isHidden = hiddenAppIds.contains(app.app.getAppId()); - - if (!app.isHidden || showHiddenApps) { - itemList.add(app); - } - } - } - else { - // Just update the isHidden state to show the correct UI indication - for (AppView.AppObject app : allApps) { - app.isHidden = hiddenAppIds.contains(app.app.getAppId()); - } - } - - notifyDataSetChanged(); - } - - private static int getLayoutIdForPreferences(PreferenceConfiguration prefs) { - if (prefs.smallIconMode) { - return R.layout.app_grid_item_small; - } - else { - return R.layout.app_grid_item; - } - } - - public void updateLayoutWithPreferences(Context context, PreferenceConfiguration prefs) { - int dpi = context.getResources().getDisplayMetrics().densityDpi; - int dp; - - if (prefs.smallIconMode) { - dp = SMALL_WIDTH_DP; - } - else { - dp = LARGE_WIDTH_DP; - } - - double scalingDivisor = ART_WIDTH_PX / (dp * (dpi / 160.0)); - if (scalingDivisor < 1.0) { - // We don't want to make them bigger before draw-time - scalingDivisor = 1.0; - } - LimeLog.info("Art scaling divisor: " + scalingDivisor); - - if (loader != null) { - // Cancel operations on the old loader - cancelQueuedOperations(); - } - - this.loader = new CachedAppAssetLoader(computer, scalingDivisor, - new NetworkAssetLoader(context, uniqueId), - new MemoryAssetLoader(), - new DiskAssetLoader(context), - BitmapFactory.decodeResource(context.getResources(), R.drawable.no_app_image)); - - // This will trigger the view to reload with the new layout - setLayoutId(getLayoutIdForPreferences(prefs)); - } - - public void cancelQueuedOperations() { - loader.cancelForegroundLoads(); - loader.cancelBackgroundLoads(); - loader.freeCacheMemory(); - } - - private static void sortList(List list) { - Collections.sort(list, new Comparator() { - @Override - public int compare(AppView.AppObject lhs, AppView.AppObject rhs) { - return lhs.app.getAppName().toLowerCase().compareTo(rhs.app.getAppName().toLowerCase()); - } - }); - } - - public void addApp(AppView.AppObject app) { - // Update hidden state - app.isHidden = hiddenAppIds.contains(app.app.getAppId()); - - // Always add the app to the all apps list - allApps.add(app); - sortList(allApps); - - // Add the app to the adapter data if it's not hidden - if (showHiddenApps || !app.isHidden) { - // Queue a request to fetch this bitmap into cache - loader.queueCacheLoad(app.app); - - // Add the app to our sorted list - itemList.add(app); - sortList(itemList); - } - } - - public void removeApp(AppView.AppObject app) { - itemList.remove(app); - allApps.remove(app); - } - - @Override - public void clear() { - super.clear(); - allApps.clear(); - } - - @Override - public void populateView(View parentView, ImageView imgView, ProgressBar prgView, TextView txtView, ImageView overlayView, AppView.AppObject obj) { - // Let the cached asset loader handle it - loader.populateImageView(obj.app, imgView, txtView); - - if (obj.isRunning) { - // Show the play button overlay - overlayView.setImageResource(R.drawable.ic_play); - overlayView.setVisibility(View.VISIBLE); - } - else { - overlayView.setVisibility(View.GONE); - } - - if (obj.isHidden) { - parentView.setAlpha(0.40f); - } - else { - parentView.setAlpha(1.0f); - } - } -} +package com.limelight.grid; + +import android.content.Context; +import android.graphics.BitmapFactory; +import android.view.View; +import android.widget.ImageView; +import android.widget.ProgressBar; +import android.widget.TextView; + +import com.limelight.AppView; +import com.limelight.LimeLog; +import com.limelight.R; +import com.limelight.grid.assets.CachedAppAssetLoader; +import com.limelight.grid.assets.DiskAssetLoader; +import com.limelight.grid.assets.MemoryAssetLoader; +import com.limelight.grid.assets.NetworkAssetLoader; +import com.limelight.nvstream.http.ComputerDetails; +import com.limelight.preferences.PreferenceConfiguration; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@SuppressWarnings("unchecked") +public class AppGridAdapter extends GenericGridAdapter { + private static final int ART_WIDTH_PX = 300; + private static final int SMALL_WIDTH_DP = 100; + private static final int LARGE_WIDTH_DP = 150; + + private final ComputerDetails computer; + private final String uniqueId; + private final boolean showHiddenApps; + + private CachedAppAssetLoader loader; + private Set hiddenAppIds = new HashSet<>(); + private ArrayList allApps = new ArrayList<>(); + + public AppGridAdapter(Context context, PreferenceConfiguration prefs, ComputerDetails computer, String uniqueId, boolean showHiddenApps) { + super(context, getLayoutIdForPreferences(prefs)); + + this.computer = computer; + this.uniqueId = uniqueId; + this.showHiddenApps = showHiddenApps; + + updateLayoutWithPreferences(context, prefs); + } + + public void updateHiddenApps(Set newHiddenAppIds, boolean hideImmediately) { + this.hiddenAppIds.clear(); + this.hiddenAppIds.addAll(newHiddenAppIds); + + if (hideImmediately) { + // Reconstruct the itemList with the new hidden app set + itemList.clear(); + for (AppView.AppObject app : allApps) { + app.isHidden = hiddenAppIds.contains(app.app.getAppId()); + + if (!app.isHidden || showHiddenApps) { + itemList.add(app); + } + } + } + else { + // Just update the isHidden state to show the correct UI indication + for (AppView.AppObject app : allApps) { + app.isHidden = hiddenAppIds.contains(app.app.getAppId()); + } + } + + notifyDataSetChanged(); + } + + private static int getLayoutIdForPreferences(PreferenceConfiguration prefs) { + if (prefs.smallIconMode) { + return R.layout.app_grid_item_small; + } + else { + return R.layout.app_grid_item; + } + } + + public void updateLayoutWithPreferences(Context context, PreferenceConfiguration prefs) { + int dpi = context.getResources().getDisplayMetrics().densityDpi; + int dp; + + if (prefs.smallIconMode) { + dp = SMALL_WIDTH_DP; + } + else { + dp = LARGE_WIDTH_DP; + } + + double scalingDivisor = ART_WIDTH_PX / (dp * (dpi / 160.0)); + if (scalingDivisor < 1.0) { + // We don't want to make them bigger before draw-time + scalingDivisor = 1.0; + } + LimeLog.info("Art scaling divisor: " + scalingDivisor); + + if (loader != null) { + // Cancel operations on the old loader + cancelQueuedOperations(); + } + + this.loader = new CachedAppAssetLoader(computer, scalingDivisor, + new NetworkAssetLoader(context, uniqueId), + new MemoryAssetLoader(), + new DiskAssetLoader(context), + BitmapFactory.decodeResource(context.getResources(), R.drawable.no_app_image)); + + // This will trigger the view to reload with the new layout + setLayoutId(getLayoutIdForPreferences(prefs)); + } + + public void cancelQueuedOperations() { + loader.cancelForegroundLoads(); + loader.cancelBackgroundLoads(); + loader.freeCacheMemory(); + } + + private static void sortList(List list) { + Collections.sort(list, new Comparator() { + @Override + public int compare(AppView.AppObject lhs, AppView.AppObject rhs) { + return lhs.app.getAppName().toLowerCase().compareTo(rhs.app.getAppName().toLowerCase()); + } + }); + } + + public void addApp(AppView.AppObject app) { + // Update hidden state + app.isHidden = hiddenAppIds.contains(app.app.getAppId()); + + // Always add the app to the all apps list + allApps.add(app); + sortList(allApps); + + // Add the app to the adapter data if it's not hidden + if (showHiddenApps || !app.isHidden) { + // Queue a request to fetch this bitmap into cache + loader.queueCacheLoad(app.app); + + // Add the app to our sorted list + itemList.add(app); + sortList(itemList); + } + } + + public void removeApp(AppView.AppObject app) { + itemList.remove(app); + allApps.remove(app); + } + + @Override + public void clear() { + super.clear(); + allApps.clear(); + } + + @Override + public void populateView(View parentView, ImageView imgView, ProgressBar prgView, TextView txtView, ImageView overlayView, AppView.AppObject obj) { + // Let the cached asset loader handle it + loader.populateImageView(obj.app, imgView, txtView); + + if (obj.isRunning) { + // Show the play button overlay + overlayView.setImageResource(R.drawable.ic_play); + overlayView.setVisibility(View.VISIBLE); + } + else { + overlayView.setVisibility(View.GONE); + } + + if (obj.isHidden) { + parentView.setAlpha(0.40f); + } + else { + parentView.setAlpha(1.0f); + } + } +} diff --git a/app/src/main/java/com/limelight/grid/GenericGridAdapter.java b/app/src/main/java/com/limelight/grid/GenericGridAdapter.java old mode 100644 new mode 100755 index cec3de5b7c..485fc8e941 --- a/app/src/main/java/com/limelight/grid/GenericGridAdapter.java +++ b/app/src/main/java/com/limelight/grid/GenericGridAdapter.java @@ -1,74 +1,74 @@ -package com.limelight.grid; - -import android.content.Context; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.BaseAdapter; -import android.widget.ImageView; -import android.widget.ProgressBar; -import android.widget.TextView; - -import com.limelight.R; - -import java.util.ArrayList; - -public abstract class GenericGridAdapter extends BaseAdapter { - protected final Context context; - private int layoutId; - final ArrayList itemList = new ArrayList<>(); - private final LayoutInflater inflater; - - GenericGridAdapter(Context context, int layoutId) { - this.context = context; - this.layoutId = layoutId; - - this.inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); - } - - void setLayoutId(int layoutId) { - if (layoutId != this.layoutId) { - this.layoutId = layoutId; - - // Force the view to be redrawn with the new layout - notifyDataSetInvalidated(); - } - } - - public void clear() { - itemList.clear(); - } - - @Override - public int getCount() { - return itemList.size(); - } - - @Override - public Object getItem(int i) { - return itemList.get(i); - } - - @Override - public long getItemId(int i) { - return i; - } - - public abstract void populateView(View parentView, ImageView imgView, ProgressBar prgView, TextView txtView, ImageView overlayView, T obj); - - @Override - public View getView(int i, View convertView, ViewGroup viewGroup) { - if (convertView == null) { - convertView = inflater.inflate(layoutId, viewGroup, false); - } - - ImageView imgView = convertView.findViewById(R.id.grid_image); - ImageView overlayView = convertView.findViewById(R.id.grid_overlay); - TextView txtView = convertView.findViewById(R.id.grid_text); - ProgressBar prgView = convertView.findViewById(R.id.grid_spinner); - - populateView(convertView, imgView, prgView, txtView, overlayView, itemList.get(i)); - - return convertView; - } -} +package com.limelight.grid; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.ImageView; +import android.widget.ProgressBar; +import android.widget.TextView; + +import com.limelight.R; + +import java.util.ArrayList; + +public abstract class GenericGridAdapter extends BaseAdapter { + protected final Context context; + private int layoutId; + final ArrayList itemList = new ArrayList<>(); + private final LayoutInflater inflater; + + GenericGridAdapter(Context context, int layoutId) { + this.context = context; + this.layoutId = layoutId; + + this.inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + } + + void setLayoutId(int layoutId) { + if (layoutId != this.layoutId) { + this.layoutId = layoutId; + + // Force the view to be redrawn with the new layout + notifyDataSetInvalidated(); + } + } + + public void clear() { + itemList.clear(); + } + + @Override + public int getCount() { + return itemList.size(); + } + + @Override + public Object getItem(int i) { + return itemList.get(i); + } + + @Override + public long getItemId(int i) { + return i; + } + + public abstract void populateView(View parentView, ImageView imgView, ProgressBar prgView, TextView txtView, ImageView overlayView, T obj); + + @Override + public View getView(int i, View convertView, ViewGroup viewGroup) { + if (convertView == null) { + convertView = inflater.inflate(layoutId, viewGroup, false); + } + + ImageView imgView = convertView.findViewById(R.id.grid_image); + ImageView overlayView = convertView.findViewById(R.id.grid_overlay); + TextView txtView = convertView.findViewById(R.id.grid_text); + ProgressBar prgView = convertView.findViewById(R.id.grid_spinner); + + populateView(convertView, imgView, prgView, txtView, overlayView, itemList.get(i)); + + return convertView; + } +} diff --git a/app/src/main/java/com/limelight/grid/PcGridAdapter.java b/app/src/main/java/com/limelight/grid/PcGridAdapter.java old mode 100644 new mode 100755 index 91e313f383..437151d5ff --- a/app/src/main/java/com/limelight/grid/PcGridAdapter.java +++ b/app/src/main/java/com/limelight/grid/PcGridAdapter.java @@ -1,93 +1,93 @@ -package com.limelight.grid; - -import android.content.Context; -import android.view.View; -import android.widget.ImageView; -import android.widget.ProgressBar; -import android.widget.TextView; - -import com.limelight.PcView; -import com.limelight.R; -import com.limelight.nvstream.http.ComputerDetails; -import com.limelight.nvstream.http.PairingManager; -import com.limelight.preferences.PreferenceConfiguration; - -import java.util.Collections; -import java.util.Comparator; - -public class PcGridAdapter extends GenericGridAdapter { - - public PcGridAdapter(Context context, PreferenceConfiguration prefs) { - super(context, getLayoutIdForPreferences(prefs)); - } - - private static int getLayoutIdForPreferences(PreferenceConfiguration prefs) { - return R.layout.pc_grid_item; - } - - public void updateLayoutWithPreferences(Context context, PreferenceConfiguration prefs) { - // This will trigger the view to reload with the new layout - setLayoutId(getLayoutIdForPreferences(prefs)); - } - - public void addComputer(PcView.ComputerObject computer) { - itemList.add(computer); - sortList(); - } - - private void sortList() { - Collections.sort(itemList, new Comparator() { - @Override - public int compare(PcView.ComputerObject lhs, PcView.ComputerObject rhs) { - return lhs.details.name.toLowerCase().compareTo(rhs.details.name.toLowerCase()); - } - }); - } - - public boolean removeComputer(PcView.ComputerObject computer) { - return itemList.remove(computer); - } - - @Override - public void populateView(View parentView, ImageView imgView, ProgressBar prgView, TextView txtView, ImageView overlayView, PcView.ComputerObject obj) { - imgView.setImageResource(R.drawable.ic_computer); - if (obj.details.state == ComputerDetails.State.ONLINE) { - imgView.setAlpha(1.0f); - } - else { - imgView.setAlpha(0.4f); - } - - if (obj.details.state == ComputerDetails.State.UNKNOWN) { - prgView.setVisibility(View.VISIBLE); - } - else { - prgView.setVisibility(View.INVISIBLE); - } - - txtView.setText(obj.details.name); - if (obj.details.state == ComputerDetails.State.ONLINE) { - txtView.setAlpha(1.0f); - } - else { - txtView.setAlpha(0.4f); - } - - if (obj.details.state == ComputerDetails.State.OFFLINE) { - overlayView.setImageResource(R.drawable.ic_pc_offline); - overlayView.setAlpha(0.4f); - overlayView.setVisibility(View.VISIBLE); - } - // We must check if the status is exactly online and unpaired - // to avoid colliding with the loading spinner when status is unknown - else if (obj.details.state == ComputerDetails.State.ONLINE && - obj.details.pairState == PairingManager.PairState.NOT_PAIRED) { - overlayView.setImageResource(R.drawable.ic_lock); - overlayView.setAlpha(1.0f); - overlayView.setVisibility(View.VISIBLE); - } - else { - overlayView.setVisibility(View.GONE); - } - } -} +package com.limelight.grid; + +import android.content.Context; +import android.view.View; +import android.widget.ImageView; +import android.widget.ProgressBar; +import android.widget.TextView; + +import com.limelight.PcView; +import com.limelight.R; +import com.limelight.nvstream.http.ComputerDetails; +import com.limelight.nvstream.http.PairingManager; +import com.limelight.preferences.PreferenceConfiguration; + +import java.util.Collections; +import java.util.Comparator; + +public class PcGridAdapter extends GenericGridAdapter { + + public PcGridAdapter(Context context, PreferenceConfiguration prefs) { + super(context, getLayoutIdForPreferences(prefs)); + } + + private static int getLayoutIdForPreferences(PreferenceConfiguration prefs) { + return R.layout.pc_grid_item; + } + + public void updateLayoutWithPreferences(Context context, PreferenceConfiguration prefs) { + // This will trigger the view to reload with the new layout + setLayoutId(getLayoutIdForPreferences(prefs)); + } + + public void addComputer(PcView.ComputerObject computer) { + itemList.add(computer); + sortList(); + } + + private void sortList() { + Collections.sort(itemList, new Comparator() { + @Override + public int compare(PcView.ComputerObject lhs, PcView.ComputerObject rhs) { + return lhs.details.name.toLowerCase().compareTo(rhs.details.name.toLowerCase()); + } + }); + } + + public boolean removeComputer(PcView.ComputerObject computer) { + return itemList.remove(computer); + } + + @Override + public void populateView(View parentView, ImageView imgView, ProgressBar prgView, TextView txtView, ImageView overlayView, PcView.ComputerObject obj) { + imgView.setImageResource(R.drawable.ic_computer); + if (obj.details.state == ComputerDetails.State.ONLINE) { + imgView.setAlpha(1.0f); + } + else { + imgView.setAlpha(0.4f); + } + + if (obj.details.state == ComputerDetails.State.UNKNOWN) { + prgView.setVisibility(View.VISIBLE); + } + else { + prgView.setVisibility(View.INVISIBLE); + } + + txtView.setText(obj.details.name); + if (obj.details.state == ComputerDetails.State.ONLINE) { + txtView.setAlpha(1.0f); + } + else { + txtView.setAlpha(0.4f); + } + + if (obj.details.state == ComputerDetails.State.OFFLINE) { + overlayView.setImageResource(R.drawable.ic_pc_offline); + overlayView.setAlpha(0.4f); + overlayView.setVisibility(View.VISIBLE); + } + // We must check if the status is exactly online and unpaired + // to avoid colliding with the loading spinner when status is unknown + else if (obj.details.state == ComputerDetails.State.ONLINE && + obj.details.pairState == PairingManager.PairState.NOT_PAIRED) { + overlayView.setImageResource(R.drawable.ic_lock); + overlayView.setAlpha(1.0f); + overlayView.setVisibility(View.VISIBLE); + } + else { + overlayView.setVisibility(View.GONE); + } + } +} diff --git a/app/src/main/java/com/limelight/grid/assets/CachedAppAssetLoader.java b/app/src/main/java/com/limelight/grid/assets/CachedAppAssetLoader.java old mode 100644 new mode 100755 index 83253b241a..5576abbe62 --- a/app/src/main/java/com/limelight/grid/assets/CachedAppAssetLoader.java +++ b/app/src/main/java/com/limelight/grid/assets/CachedAppAssetLoader.java @@ -1,396 +1,396 @@ -package com.limelight.grid.assets; - -import android.content.res.Resources; -import android.graphics.Bitmap; -import android.graphics.drawable.BitmapDrawable; -import android.graphics.drawable.Drawable; -import android.os.AsyncTask; -import android.view.View; -import android.view.animation.Animation; -import android.view.animation.AnimationUtils; -import android.widget.ImageView; -import android.widget.TextView; - -import com.limelight.R; -import com.limelight.nvstream.http.ComputerDetails; -import com.limelight.nvstream.http.NvApp; - -import java.io.IOException; -import java.io.InputStream; -import java.lang.ref.WeakReference; -import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.ThreadPoolExecutor; -import java.util.concurrent.TimeUnit; - -public class CachedAppAssetLoader { - private static final int MAX_CONCURRENT_DISK_LOADS = 3; - private static final int MAX_CONCURRENT_NETWORK_LOADS = 3; - private static final int MAX_CONCURRENT_CACHE_LOADS = 1; - - private static final int MAX_PENDING_CACHE_LOADS = 100; - private static final int MAX_PENDING_NETWORK_LOADS = 40; - private static final int MAX_PENDING_DISK_LOADS = 40; - - private final ThreadPoolExecutor cacheExecutor = new ThreadPoolExecutor( - MAX_CONCURRENT_CACHE_LOADS, MAX_CONCURRENT_CACHE_LOADS, - Long.MAX_VALUE, TimeUnit.DAYS, - new LinkedBlockingQueue(MAX_PENDING_CACHE_LOADS), - new ThreadPoolExecutor.DiscardOldestPolicy()); - - private final ThreadPoolExecutor foregroundExecutor = new ThreadPoolExecutor( - MAX_CONCURRENT_DISK_LOADS, MAX_CONCURRENT_DISK_LOADS, - Long.MAX_VALUE, TimeUnit.DAYS, - new LinkedBlockingQueue(MAX_PENDING_DISK_LOADS), - new ThreadPoolExecutor.DiscardOldestPolicy()); - - private final ThreadPoolExecutor networkExecutor = new ThreadPoolExecutor( - MAX_CONCURRENT_NETWORK_LOADS, MAX_CONCURRENT_NETWORK_LOADS, - Long.MAX_VALUE, TimeUnit.DAYS, - new LinkedBlockingQueue(MAX_PENDING_NETWORK_LOADS), - new ThreadPoolExecutor.DiscardOldestPolicy()); - - private final ComputerDetails computer; - private final double scalingDivider; - private final NetworkAssetLoader networkLoader; - private final MemoryAssetLoader memoryLoader; - private final DiskAssetLoader diskLoader; - private final Bitmap placeholderBitmap; - private final Bitmap noAppImageBitmap; - - public CachedAppAssetLoader(ComputerDetails computer, double scalingDivider, - NetworkAssetLoader networkLoader, MemoryAssetLoader memoryLoader, - DiskAssetLoader diskLoader, Bitmap noAppImageBitmap) { - this.computer = computer; - this.scalingDivider = scalingDivider; - this.networkLoader = networkLoader; - this.memoryLoader = memoryLoader; - this.diskLoader = diskLoader; - this.noAppImageBitmap = noAppImageBitmap; - this.placeholderBitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888); - } - - public void cancelBackgroundLoads() { - Runnable r; - while ((r = cacheExecutor.getQueue().poll()) != null) { - cacheExecutor.remove(r); - } - } - - public void cancelForegroundLoads() { - Runnable r; - - while ((r = foregroundExecutor.getQueue().poll()) != null) { - foregroundExecutor.remove(r); - } - - while ((r = networkExecutor.getQueue().poll()) != null) { - networkExecutor.remove(r); - } - } - - public void freeCacheMemory() { - memoryLoader.clearCache(); - } - - private ScaledBitmap doNetworkAssetLoad(LoaderTuple tuple, LoaderTask task) { - // Try 3 times - for (int i = 0; i < 3; i++) { - // Check again whether we've been cancelled or the image view is gone - if (task != null && (task.isCancelled() || task.imageViewRef.get() == null)) { - return null; - } - - InputStream in = networkLoader.getBitmapStream(tuple); - if (in != null) { - // Write the stream straight to disk - diskLoader.populateCacheWithStream(tuple, in); - - // Close the network input stream - try { - in.close(); - } catch (IOException ignored) {} - - // If there's a task associated with this load, we should return the bitmap - if (task != null) { - // If the cached bitmap is valid, return it. Otherwise, we'll try the load again - ScaledBitmap bmp = diskLoader.loadBitmapFromCache(tuple, (int) scalingDivider); - if (bmp != null) { - return bmp; - } - } - else { - // Otherwise it's a background load and we return nothing - return null; - } - } - - // Wait 1 second with a bit of fuzz - try { - Thread.sleep((int) (1000 + (Math.random() * 500))); - } catch (InterruptedException e) { - e.printStackTrace(); - - // InterruptedException clears the thread's interrupt status. Since we can't - // handle that here, we will re-interrupt the thread to set the interrupt - // status back to true. - Thread.currentThread().interrupt(); - - return null; - } - } - - return null; - } - - private class LoaderTask extends AsyncTask { - private final WeakReference imageViewRef; - private final WeakReference textViewRef; - private final boolean diskOnly; - - private LoaderTuple tuple; - - public LoaderTask(ImageView imageView, TextView textView, boolean diskOnly) { - this.imageViewRef = new WeakReference<>(imageView); - this.textViewRef = new WeakReference<>(textView); - this.diskOnly = diskOnly; - } - - @Override - protected ScaledBitmap doInBackground(LoaderTuple... params) { - tuple = params[0]; - - // Check whether it has been cancelled or the views are gone - if (isCancelled() || imageViewRef.get() == null || textViewRef.get() == null) { - return null; - } - - ScaledBitmap bmp = diskLoader.loadBitmapFromCache(tuple, (int) scalingDivider); - if (bmp == null) { - if (!diskOnly) { - // Try to load the asset from the network - bmp = doNetworkAssetLoad(tuple, this); - } else { - // Report progress to display the placeholder and spin - // off the network-capable task - publishProgress(); - } - } - - // Cache the bitmap - if (bmp != null) { - memoryLoader.populateCache(tuple, bmp); - } - - return bmp; - } - - @Override - protected void onProgressUpdate(Void... nothing) { - // Do nothing if cancelled - if (isCancelled()) { - return; - } - - // If the current loader task for this view isn't us, do nothing - final ImageView imageView = imageViewRef.get(); - final TextView textView = textViewRef.get(); - if (getLoaderTask(imageView) == this) { - // Set off another loader task on the network executor. This time our AsyncDrawable - // will use the app image placeholder bitmap, rather than an empty bitmap. - LoaderTask task = new LoaderTask(imageView, textView, false); - AsyncDrawable asyncDrawable = new AsyncDrawable(imageView.getResources(), noAppImageBitmap, task); - imageView.setImageDrawable(asyncDrawable); - imageView.startAnimation(AnimationUtils.loadAnimation(imageView.getContext(), R.anim.boxart_fadein)); - imageView.setVisibility(View.VISIBLE); - textView.setVisibility(View.VISIBLE); - task.executeOnExecutor(networkExecutor, tuple); - } - } - - @Override - protected void onPostExecute(final ScaledBitmap bitmap) { - // Do nothing if cancelled - if (isCancelled()) { - return; - } - - final ImageView imageView = imageViewRef.get(); - final TextView textView = textViewRef.get(); - if (getLoaderTask(imageView) == this) { - // Fade in the box art - if (bitmap != null) { - // Show the text if it's a placeholder - textView.setVisibility(isBitmapPlaceholder(bitmap) ? View.VISIBLE : View.GONE); - - if (imageView.getVisibility() == View.VISIBLE) { - // Fade out the placeholder first - Animation fadeOutAnimation = AnimationUtils.loadAnimation(imageView.getContext(), R.anim.boxart_fadeout); - fadeOutAnimation.setAnimationListener(new Animation.AnimationListener() { - @Override - public void onAnimationStart(Animation animation) {} - - @Override - public void onAnimationEnd(Animation animation) { - // Fade in the new box art - imageView.setImageBitmap(bitmap.bitmap); - imageView.startAnimation(AnimationUtils.loadAnimation(imageView.getContext(), R.anim.boxart_fadein)); - } - - @Override - public void onAnimationRepeat(Animation animation) {} - }); - imageView.startAnimation(fadeOutAnimation); - } - else { - // View is invisible already, so just fade in the new art - imageView.setImageBitmap(bitmap.bitmap); - imageView.startAnimation(AnimationUtils.loadAnimation(imageView.getContext(), R.anim.boxart_fadein)); - imageView.setVisibility(View.VISIBLE); - } - } - } - } - } - - static class AsyncDrawable extends BitmapDrawable { - private final WeakReference loaderTaskReference; - - public AsyncDrawable(Resources res, Bitmap bitmap, - LoaderTask loaderTask) { - super(res, bitmap); - loaderTaskReference = new WeakReference<>(loaderTask); - } - - public LoaderTask getLoaderTask() { - return loaderTaskReference.get(); - } - } - - private static LoaderTask getLoaderTask(ImageView imageView) { - if (imageView == null) { - return null; - } - - final Drawable drawable = imageView.getDrawable(); - - // If our drawable is in play, get the loader task - if (drawable instanceof AsyncDrawable) { - final AsyncDrawable asyncDrawable = (AsyncDrawable) drawable; - return asyncDrawable.getLoaderTask(); - } - - return null; - } - - private static boolean cancelPendingLoad(LoaderTuple tuple, ImageView imageView) { - final LoaderTask loaderTask = getLoaderTask(imageView); - - // Check if any task was pending for this image view - if (loaderTask != null && !loaderTask.isCancelled()) { - final LoaderTuple taskTuple = loaderTask.tuple; - - // Cancel the task if it's not already loading the same data - if (taskTuple == null || !taskTuple.equals(tuple)) { - loaderTask.cancel(true); - } else { - // It's already loading what we want - return false; - } - } - - // Allow the load to proceed - return true; - } - - public void queueCacheLoad(NvApp app) { - final LoaderTuple tuple = new LoaderTuple(computer, app); - - if (memoryLoader.loadBitmapFromCache(tuple) != null) { - // It's in memory which means it must also be on disk - return; - } - - // Queue a fetch in the cache executor - cacheExecutor.execute(new Runnable() { - @Override - public void run() { - // Check if the image is cached on disk - if (diskLoader.checkCacheExists(tuple)) { - return; - } - - // Try to load the asset from the network and cache result on disk - doNetworkAssetLoad(tuple, null); - } - }); - } - - private boolean isBitmapPlaceholder(ScaledBitmap bitmap) { - return (bitmap == null) || - (bitmap.originalWidth == 130 && bitmap.originalHeight == 180) || // GFE 2.0 - (bitmap.originalWidth == 628 && bitmap.originalHeight == 888); // GFE 3.0 - } - - public boolean populateImageView(NvApp app, ImageView imgView, TextView textView) { - LoaderTuple tuple = new LoaderTuple(computer, app); - - // If there's already a task in progress for this view, - // cancel it. If the task is already loading the same image, - // we return and let that load finish. - if (!cancelPendingLoad(tuple, imgView)) { - return true; - } - - // Always set the name text so we have it if needed later - textView.setText(app.getAppName()); - - // First, try the memory cache in the current context - ScaledBitmap bmp = memoryLoader.loadBitmapFromCache(tuple); - if (bmp != null) { - // Show the bitmap immediately - imgView.setVisibility(View.VISIBLE); - imgView.setImageBitmap(bmp.bitmap); - - // Show the text if it's a placeholder bitmap - textView.setVisibility(isBitmapPlaceholder(bmp) ? View.VISIBLE : View.GONE); - return true; - } - - // If it's not in memory, create an async task to load it. This task will be attached - // via AsyncDrawable to this view. - final LoaderTask task = new LoaderTask(imgView, textView, true); - final AsyncDrawable asyncDrawable = new AsyncDrawable(imgView.getResources(), placeholderBitmap, task); - textView.setVisibility(View.INVISIBLE); - imgView.setVisibility(View.INVISIBLE); - imgView.setImageDrawable(asyncDrawable); - - // Run the task on our foreground executor - task.executeOnExecutor(foregroundExecutor, tuple); - return false; - } - - public static class LoaderTuple { - public final ComputerDetails computer; - public final NvApp app; - - public LoaderTuple(ComputerDetails computer, NvApp app) { - this.computer = computer; - this.app = app; - } - - @Override - public boolean equals(Object o) { - if (!(o instanceof LoaderTuple)) { - return false; - } - - LoaderTuple other = (LoaderTuple) o; - return computer.uuid.equals(other.computer.uuid) && app.getAppId() == other.app.getAppId(); - } - - @Override - public String toString() { - return "("+computer.uuid+", "+app.getAppId()+")"; - } - } -} +package com.limelight.grid.assets; + +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.os.AsyncTask; +import android.view.View; +import android.view.animation.Animation; +import android.view.animation.AnimationUtils; +import android.widget.ImageView; +import android.widget.TextView; + +import com.limelight.R; +import com.limelight.nvstream.http.ComputerDetails; +import com.limelight.nvstream.http.NvApp; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.ref.WeakReference; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +public class CachedAppAssetLoader { + private static final int MAX_CONCURRENT_DISK_LOADS = 3; + private static final int MAX_CONCURRENT_NETWORK_LOADS = 3; + private static final int MAX_CONCURRENT_CACHE_LOADS = 1; + + private static final int MAX_PENDING_CACHE_LOADS = 100; + private static final int MAX_PENDING_NETWORK_LOADS = 40; + private static final int MAX_PENDING_DISK_LOADS = 40; + + private final ThreadPoolExecutor cacheExecutor = new ThreadPoolExecutor( + MAX_CONCURRENT_CACHE_LOADS, MAX_CONCURRENT_CACHE_LOADS, + Long.MAX_VALUE, TimeUnit.DAYS, + new LinkedBlockingQueue(MAX_PENDING_CACHE_LOADS), + new ThreadPoolExecutor.DiscardOldestPolicy()); + + private final ThreadPoolExecutor foregroundExecutor = new ThreadPoolExecutor( + MAX_CONCURRENT_DISK_LOADS, MAX_CONCURRENT_DISK_LOADS, + Long.MAX_VALUE, TimeUnit.DAYS, + new LinkedBlockingQueue(MAX_PENDING_DISK_LOADS), + new ThreadPoolExecutor.DiscardOldestPolicy()); + + private final ThreadPoolExecutor networkExecutor = new ThreadPoolExecutor( + MAX_CONCURRENT_NETWORK_LOADS, MAX_CONCURRENT_NETWORK_LOADS, + Long.MAX_VALUE, TimeUnit.DAYS, + new LinkedBlockingQueue(MAX_PENDING_NETWORK_LOADS), + new ThreadPoolExecutor.DiscardOldestPolicy()); + + private final ComputerDetails computer; + private final double scalingDivider; + private final NetworkAssetLoader networkLoader; + private final MemoryAssetLoader memoryLoader; + private final DiskAssetLoader diskLoader; + private final Bitmap placeholderBitmap; + private final Bitmap noAppImageBitmap; + + public CachedAppAssetLoader(ComputerDetails computer, double scalingDivider, + NetworkAssetLoader networkLoader, MemoryAssetLoader memoryLoader, + DiskAssetLoader diskLoader, Bitmap noAppImageBitmap) { + this.computer = computer; + this.scalingDivider = scalingDivider; + this.networkLoader = networkLoader; + this.memoryLoader = memoryLoader; + this.diskLoader = diskLoader; + this.noAppImageBitmap = noAppImageBitmap; + this.placeholderBitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888); + } + + public void cancelBackgroundLoads() { + Runnable r; + while ((r = cacheExecutor.getQueue().poll()) != null) { + cacheExecutor.remove(r); + } + } + + public void cancelForegroundLoads() { + Runnable r; + + while ((r = foregroundExecutor.getQueue().poll()) != null) { + foregroundExecutor.remove(r); + } + + while ((r = networkExecutor.getQueue().poll()) != null) { + networkExecutor.remove(r); + } + } + + public void freeCacheMemory() { + memoryLoader.clearCache(); + } + + private ScaledBitmap doNetworkAssetLoad(LoaderTuple tuple, LoaderTask task) { + // Try 3 times + for (int i = 0; i < 3; i++) { + // Check again whether we've been cancelled or the image view is gone + if (task != null && (task.isCancelled() || task.imageViewRef.get() == null)) { + return null; + } + + InputStream in = networkLoader.getBitmapStream(tuple); + if (in != null) { + // Write the stream straight to disk + diskLoader.populateCacheWithStream(tuple, in); + + // Close the network input stream + try { + in.close(); + } catch (IOException ignored) {} + + // If there's a task associated with this load, we should return the bitmap + if (task != null) { + // If the cached bitmap is valid, return it. Otherwise, we'll try the load again + ScaledBitmap bmp = diskLoader.loadBitmapFromCache(tuple, (int) scalingDivider); + if (bmp != null) { + return bmp; + } + } + else { + // Otherwise it's a background load and we return nothing + return null; + } + } + + // Wait 1 second with a bit of fuzz + try { + Thread.sleep((int) (1000 + (Math.random() * 500))); + } catch (InterruptedException e) { + e.printStackTrace(); + + // InterruptedException clears the thread's interrupt status. Since we can't + // handle that here, we will re-interrupt the thread to set the interrupt + // status back to true. + Thread.currentThread().interrupt(); + + return null; + } + } + + return null; + } + + private class LoaderTask extends AsyncTask { + private final WeakReference imageViewRef; + private final WeakReference textViewRef; + private final boolean diskOnly; + + private LoaderTuple tuple; + + public LoaderTask(ImageView imageView, TextView textView, boolean diskOnly) { + this.imageViewRef = new WeakReference<>(imageView); + this.textViewRef = new WeakReference<>(textView); + this.diskOnly = diskOnly; + } + + @Override + protected ScaledBitmap doInBackground(LoaderTuple... params) { + tuple = params[0]; + + // Check whether it has been cancelled or the views are gone + if (isCancelled() || imageViewRef.get() == null || textViewRef.get() == null) { + return null; + } + + ScaledBitmap bmp = diskLoader.loadBitmapFromCache(tuple, (int) scalingDivider); + if (bmp == null) { + if (!diskOnly) { + // Try to load the asset from the network + bmp = doNetworkAssetLoad(tuple, this); + } else { + // Report progress to display the placeholder and spin + // off the network-capable task + publishProgress(); + } + } + + // Cache the bitmap + if (bmp != null) { + memoryLoader.populateCache(tuple, bmp); + } + + return bmp; + } + + @Override + protected void onProgressUpdate(Void... nothing) { + // Do nothing if cancelled + if (isCancelled()) { + return; + } + + // If the current loader task for this view isn't us, do nothing + final ImageView imageView = imageViewRef.get(); + final TextView textView = textViewRef.get(); + if (getLoaderTask(imageView) == this) { + // Set off another loader task on the network executor. This time our AsyncDrawable + // will use the app image placeholder bitmap, rather than an empty bitmap. + LoaderTask task = new LoaderTask(imageView, textView, false); + AsyncDrawable asyncDrawable = new AsyncDrawable(imageView.getResources(), noAppImageBitmap, task); + imageView.setImageDrawable(asyncDrawable); + imageView.startAnimation(AnimationUtils.loadAnimation(imageView.getContext(), R.anim.boxart_fadein)); + imageView.setVisibility(View.VISIBLE); + textView.setVisibility(View.VISIBLE); + task.executeOnExecutor(networkExecutor, tuple); + } + } + + @Override + protected void onPostExecute(final ScaledBitmap bitmap) { + // Do nothing if cancelled + if (isCancelled()) { + return; + } + + final ImageView imageView = imageViewRef.get(); + final TextView textView = textViewRef.get(); + if (getLoaderTask(imageView) == this) { + // Fade in the box art + if (bitmap != null) { + // Show the text if it's a placeholder + textView.setVisibility(isBitmapPlaceholder(bitmap) ? View.VISIBLE : View.GONE); + + if (imageView.getVisibility() == View.VISIBLE) { + // Fade out the placeholder first + Animation fadeOutAnimation = AnimationUtils.loadAnimation(imageView.getContext(), R.anim.boxart_fadeout); + fadeOutAnimation.setAnimationListener(new Animation.AnimationListener() { + @Override + public void onAnimationStart(Animation animation) {} + + @Override + public void onAnimationEnd(Animation animation) { + // Fade in the new box art + imageView.setImageBitmap(bitmap.bitmap); + imageView.startAnimation(AnimationUtils.loadAnimation(imageView.getContext(), R.anim.boxart_fadein)); + } + + @Override + public void onAnimationRepeat(Animation animation) {} + }); + imageView.startAnimation(fadeOutAnimation); + } + else { + // View is invisible already, so just fade in the new art + imageView.setImageBitmap(bitmap.bitmap); + imageView.startAnimation(AnimationUtils.loadAnimation(imageView.getContext(), R.anim.boxart_fadein)); + imageView.setVisibility(View.VISIBLE); + } + } + } + } + } + + static class AsyncDrawable extends BitmapDrawable { + private final WeakReference loaderTaskReference; + + public AsyncDrawable(Resources res, Bitmap bitmap, + LoaderTask loaderTask) { + super(res, bitmap); + loaderTaskReference = new WeakReference<>(loaderTask); + } + + public LoaderTask getLoaderTask() { + return loaderTaskReference.get(); + } + } + + private static LoaderTask getLoaderTask(ImageView imageView) { + if (imageView == null) { + return null; + } + + final Drawable drawable = imageView.getDrawable(); + + // If our drawable is in play, get the loader task + if (drawable instanceof AsyncDrawable) { + final AsyncDrawable asyncDrawable = (AsyncDrawable) drawable; + return asyncDrawable.getLoaderTask(); + } + + return null; + } + + private static boolean cancelPendingLoad(LoaderTuple tuple, ImageView imageView) { + final LoaderTask loaderTask = getLoaderTask(imageView); + + // Check if any task was pending for this image view + if (loaderTask != null && !loaderTask.isCancelled()) { + final LoaderTuple taskTuple = loaderTask.tuple; + + // Cancel the task if it's not already loading the same data + if (taskTuple == null || !taskTuple.equals(tuple)) { + loaderTask.cancel(true); + } else { + // It's already loading what we want + return false; + } + } + + // Allow the load to proceed + return true; + } + + public void queueCacheLoad(NvApp app) { + final LoaderTuple tuple = new LoaderTuple(computer, app); + + if (memoryLoader.loadBitmapFromCache(tuple) != null) { + // It's in memory which means it must also be on disk + return; + } + + // Queue a fetch in the cache executor + cacheExecutor.execute(new Runnable() { + @Override + public void run() { + // Check if the image is cached on disk + if (diskLoader.checkCacheExists(tuple)) { + return; + } + + // Try to load the asset from the network and cache result on disk + doNetworkAssetLoad(tuple, null); + } + }); + } + + private boolean isBitmapPlaceholder(ScaledBitmap bitmap) { + return (bitmap == null) || + (bitmap.originalWidth == 130 && bitmap.originalHeight == 180) || // GFE 2.0 + (bitmap.originalWidth == 628 && bitmap.originalHeight == 888); // GFE 3.0 + } + + public boolean populateImageView(NvApp app, ImageView imgView, TextView textView) { + LoaderTuple tuple = new LoaderTuple(computer, app); + + // If there's already a task in progress for this view, + // cancel it. If the task is already loading the same image, + // we return and let that load finish. + if (!cancelPendingLoad(tuple, imgView)) { + return true; + } + + // Always set the name text so we have it if needed later + textView.setText(app.getAppName()); + + // First, try the memory cache in the current context + ScaledBitmap bmp = memoryLoader.loadBitmapFromCache(tuple); + if (bmp != null) { + // Show the bitmap immediately + imgView.setVisibility(View.VISIBLE); + imgView.setImageBitmap(bmp.bitmap); + + // Show the text if it's a placeholder bitmap + textView.setVisibility(isBitmapPlaceholder(bmp) ? View.VISIBLE : View.GONE); + return true; + } + + // If it's not in memory, create an async task to load it. This task will be attached + // via AsyncDrawable to this view. + final LoaderTask task = new LoaderTask(imgView, textView, true); + final AsyncDrawable asyncDrawable = new AsyncDrawable(imgView.getResources(), placeholderBitmap, task); + textView.setVisibility(View.INVISIBLE); + imgView.setVisibility(View.INVISIBLE); + imgView.setImageDrawable(asyncDrawable); + + // Run the task on our foreground executor + task.executeOnExecutor(foregroundExecutor, tuple); + return false; + } + + public static class LoaderTuple { + public final ComputerDetails computer; + public final NvApp app; + + public LoaderTuple(ComputerDetails computer, NvApp app) { + this.computer = computer; + this.app = app; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof LoaderTuple)) { + return false; + } + + LoaderTuple other = (LoaderTuple) o; + return computer.uuid.equals(other.computer.uuid) && app.getAppId() == other.app.getAppId(); + } + + @Override + public String toString() { + return "("+computer.uuid+", "+app.getAppId()+")"; + } + } +} diff --git a/app/src/main/java/com/limelight/grid/assets/DiskAssetLoader.java b/app/src/main/java/com/limelight/grid/assets/DiskAssetLoader.java old mode 100644 new mode 100755 index dc496a6bbd..b12d0d1e8b --- a/app/src/main/java/com/limelight/grid/assets/DiskAssetLoader.java +++ b/app/src/main/java/com/limelight/grid/assets/DiskAssetLoader.java @@ -1,166 +1,166 @@ -package com.limelight.grid.assets; - -import android.app.ActivityManager; -import android.content.Context; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.graphics.ImageDecoder; -import android.os.Build; - -import com.limelight.LimeLog; -import com.limelight.utils.CacheHelper; - -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; - -public class DiskAssetLoader { - // 5 MB - private static final long MAX_ASSET_SIZE = 5 * 1024 * 1024; - - // Standard box art is 300x400 - private static final int STANDARD_ASSET_WIDTH = 300; - private static final int STANDARD_ASSET_HEIGHT = 400; - - private final boolean isLowRamDevice; - private final File cacheDir; - - public DiskAssetLoader(Context context) { - this.cacheDir = context.getCacheDir(); - this.isLowRamDevice = - ((ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE)).isLowRamDevice(); - } - - public boolean checkCacheExists(CachedAppAssetLoader.LoaderTuple tuple) { - return CacheHelper.cacheFileExists(cacheDir, "boxart", tuple.computer.uuid, tuple.app.getAppId() + ".png"); - } - - // https://developer.android.com/topic/performance/graphics/load-bitmap.html - public static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) { - // Raw height and width of image - final int height = options.outHeight; - final int width = options.outWidth; - int inSampleSize = 1; - - if (height > reqHeight || width > reqWidth) { - - final int halfHeight = height / 2; - final int halfWidth = width / 2; - - // Calculates the largest inSampleSize value that is a power of 2 and keeps both - // height and width larger than the requested height and width. - while ((halfHeight / inSampleSize) >= reqHeight && (halfWidth / inSampleSize) >= reqWidth) { - inSampleSize *= 2; - } - } - - return inSampleSize; - } - - public ScaledBitmap loadBitmapFromCache(CachedAppAssetLoader.LoaderTuple tuple, int sampleSize) { - File file = getFile(tuple.computer.uuid, tuple.app.getAppId()); - - // Don't bother with anything if it doesn't exist - if (!file.exists()) { - return null; - } - - // Make sure the cached asset doesn't exceed the maximum size - if (file.length() > MAX_ASSET_SIZE) { - LimeLog.warning("Removing cached tuple exceeding size threshold: "+tuple); - file.delete(); - return null; - } - - Bitmap bmp; - - // For OSes prior to P, we have to use the ugly BitmapFactory API - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { - // Lookup bounds of the downloaded image - BitmapFactory.Options decodeOnlyOptions = new BitmapFactory.Options(); - decodeOnlyOptions.inJustDecodeBounds = true; - BitmapFactory.decodeFile(file.getAbsolutePath(), decodeOnlyOptions); - if (decodeOnlyOptions.outWidth <= 0 || decodeOnlyOptions.outHeight <= 0) { - // Dimensions set to -1 on error. Return value always null. - return null; - } - - LimeLog.info("Tuple "+tuple+" has cached art of size: "+decodeOnlyOptions.outWidth+"x"+decodeOnlyOptions.outHeight); - - // Load the image scaled to the appropriate size - BitmapFactory.Options options = new BitmapFactory.Options(); - options.inSampleSize = calculateInSampleSize(decodeOnlyOptions, - STANDARD_ASSET_WIDTH / sampleSize, - STANDARD_ASSET_HEIGHT / sampleSize); - if (isLowRamDevice) { - options.inPreferredConfig = Bitmap.Config.RGB_565; - options.inDither = true; - } - else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - options.inPreferredConfig = Bitmap.Config.HARDWARE; - } - - bmp = BitmapFactory.decodeFile(file.getAbsolutePath(), options); - if (bmp != null) { - LimeLog.info("Tuple "+tuple+" decoded from disk cache with sample size: "+options.inSampleSize); - return new ScaledBitmap(decodeOnlyOptions.outWidth, decodeOnlyOptions.outHeight, bmp); - } - } - else { - // On P, we can get a bitmap back in one step with ImageDecoder - final ScaledBitmap scaledBitmap = new ScaledBitmap(); - try { - scaledBitmap.bitmap = ImageDecoder.decodeBitmap(ImageDecoder.createSource(file), new ImageDecoder.OnHeaderDecodedListener() { - @Override - public void onHeaderDecoded(ImageDecoder imageDecoder, ImageDecoder.ImageInfo imageInfo, ImageDecoder.Source source) { - scaledBitmap.originalWidth = imageInfo.getSize().getWidth(); - scaledBitmap.originalHeight = imageInfo.getSize().getHeight(); - - imageDecoder.setTargetSize(STANDARD_ASSET_WIDTH, STANDARD_ASSET_HEIGHT); - if (isLowRamDevice) { - imageDecoder.setMemorySizePolicy(ImageDecoder.MEMORY_POLICY_LOW_RAM); - } - } - }); - return scaledBitmap; - } catch (IOException e) { - e.printStackTrace(); - return null; - } - } - - return null; - } - - public File getFile(String computerUuid, int appId) { - return CacheHelper.openPath(false, cacheDir, "boxart", computerUuid, appId + ".png"); - } - - public void deleteAssetsForComputer(String computerUuid) { - File dir = CacheHelper.openPath(false, cacheDir, "boxart", computerUuid); - File[] files = dir.listFiles(); - if (files != null) { - for (File f : files) { - f.delete(); - } - } - } - - public void populateCacheWithStream(CachedAppAssetLoader.LoaderTuple tuple, InputStream input) { - boolean success = false; - try (final OutputStream out = CacheHelper.openCacheFileForOutput( - cacheDir, "boxart", tuple.computer.uuid, tuple.app.getAppId() + ".png") - ) { - CacheHelper.writeInputStreamToOutputStream(input, out, MAX_ASSET_SIZE); - success = true; - } catch (IOException e) { - e.printStackTrace(); - } finally { - if (!success) { - LimeLog.warning("Unable to populate cache with tuple: "+tuple); - CacheHelper.deleteCacheFile(cacheDir, "boxart", tuple.computer.uuid, tuple.app.getAppId() + ".png"); - } - } - } -} +package com.limelight.grid.assets; + +import android.app.ActivityManager; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.ImageDecoder; +import android.os.Build; + +import com.limelight.LimeLog; +import com.limelight.utils.CacheHelper; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +public class DiskAssetLoader { + // 5 MB + private static final long MAX_ASSET_SIZE = 5 * 1024 * 1024; + + // Standard box art is 300x400 + private static final int STANDARD_ASSET_WIDTH = 300; + private static final int STANDARD_ASSET_HEIGHT = 400; + + private final boolean isLowRamDevice; + private final File cacheDir; + + public DiskAssetLoader(Context context) { + this.cacheDir = context.getCacheDir(); + this.isLowRamDevice = + ((ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE)).isLowRamDevice(); + } + + public boolean checkCacheExists(CachedAppAssetLoader.LoaderTuple tuple) { + return CacheHelper.cacheFileExists(cacheDir, "boxart", tuple.computer.uuid, tuple.app.getAppId() + ".png"); + } + + // https://developer.android.com/topic/performance/graphics/load-bitmap.html + public static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) { + // Raw height and width of image + final int height = options.outHeight; + final int width = options.outWidth; + int inSampleSize = 1; + + if (height > reqHeight || width > reqWidth) { + + final int halfHeight = height / 2; + final int halfWidth = width / 2; + + // Calculates the largest inSampleSize value that is a power of 2 and keeps both + // height and width larger than the requested height and width. + while ((halfHeight / inSampleSize) >= reqHeight && (halfWidth / inSampleSize) >= reqWidth) { + inSampleSize *= 2; + } + } + + return inSampleSize; + } + + public ScaledBitmap loadBitmapFromCache(CachedAppAssetLoader.LoaderTuple tuple, int sampleSize) { + File file = getFile(tuple.computer.uuid, tuple.app.getAppId()); + + // Don't bother with anything if it doesn't exist + if (!file.exists()) { + return null; + } + + // Make sure the cached asset doesn't exceed the maximum size + if (file.length() > MAX_ASSET_SIZE) { + LimeLog.warning("Removing cached tuple exceeding size threshold: "+tuple); + file.delete(); + return null; + } + + Bitmap bmp; + + // For OSes prior to P, we have to use the ugly BitmapFactory API + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + // Lookup bounds of the downloaded image + BitmapFactory.Options decodeOnlyOptions = new BitmapFactory.Options(); + decodeOnlyOptions.inJustDecodeBounds = true; + BitmapFactory.decodeFile(file.getAbsolutePath(), decodeOnlyOptions); + if (decodeOnlyOptions.outWidth <= 0 || decodeOnlyOptions.outHeight <= 0) { + // Dimensions set to -1 on error. Return value always null. + return null; + } + + LimeLog.info("Tuple "+tuple+" has cached art of size: "+decodeOnlyOptions.outWidth+"x"+decodeOnlyOptions.outHeight); + + // Load the image scaled to the appropriate size + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inSampleSize = calculateInSampleSize(decodeOnlyOptions, + STANDARD_ASSET_WIDTH / sampleSize, + STANDARD_ASSET_HEIGHT / sampleSize); + if (isLowRamDevice) { + options.inPreferredConfig = Bitmap.Config.RGB_565; + options.inDither = true; + } + else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + options.inPreferredConfig = Bitmap.Config.HARDWARE; + } + + bmp = BitmapFactory.decodeFile(file.getAbsolutePath(), options); + if (bmp != null) { + LimeLog.info("Tuple "+tuple+" decoded from disk cache with sample size: "+options.inSampleSize); + return new ScaledBitmap(decodeOnlyOptions.outWidth, decodeOnlyOptions.outHeight, bmp); + } + } + else { + // On P, we can get a bitmap back in one step with ImageDecoder + final ScaledBitmap scaledBitmap = new ScaledBitmap(); + try { + scaledBitmap.bitmap = ImageDecoder.decodeBitmap(ImageDecoder.createSource(file), new ImageDecoder.OnHeaderDecodedListener() { + @Override + public void onHeaderDecoded(ImageDecoder imageDecoder, ImageDecoder.ImageInfo imageInfo, ImageDecoder.Source source) { + scaledBitmap.originalWidth = imageInfo.getSize().getWidth(); + scaledBitmap.originalHeight = imageInfo.getSize().getHeight(); + + imageDecoder.setTargetSize(STANDARD_ASSET_WIDTH, STANDARD_ASSET_HEIGHT); + if (isLowRamDevice) { + imageDecoder.setMemorySizePolicy(ImageDecoder.MEMORY_POLICY_LOW_RAM); + } + } + }); + return scaledBitmap; + } catch (IOException e) { + e.printStackTrace(); + return null; + } + } + + return null; + } + + public File getFile(String computerUuid, int appId) { + return CacheHelper.openPath(false, cacheDir, "boxart", computerUuid, appId + ".png"); + } + + public void deleteAssetsForComputer(String computerUuid) { + File dir = CacheHelper.openPath(false, cacheDir, "boxart", computerUuid); + File[] files = dir.listFiles(); + if (files != null) { + for (File f : files) { + f.delete(); + } + } + } + + public void populateCacheWithStream(CachedAppAssetLoader.LoaderTuple tuple, InputStream input) { + boolean success = false; + try (final OutputStream out = CacheHelper.openCacheFileForOutput( + cacheDir, "boxart", tuple.computer.uuid, tuple.app.getAppId() + ".png") + ) { + CacheHelper.writeInputStreamToOutputStream(input, out, MAX_ASSET_SIZE); + success = true; + } catch (IOException e) { + e.printStackTrace(); + } finally { + if (!success) { + LimeLog.warning("Unable to populate cache with tuple: "+tuple); + CacheHelper.deleteCacheFile(cacheDir, "boxart", tuple.computer.uuid, tuple.app.getAppId() + ".png"); + } + } + } +} diff --git a/app/src/main/java/com/limelight/grid/assets/MemoryAssetLoader.java b/app/src/main/java/com/limelight/grid/assets/MemoryAssetLoader.java old mode 100644 new mode 100755 index aab6651b93..a5a4b54689 --- a/app/src/main/java/com/limelight/grid/assets/MemoryAssetLoader.java +++ b/app/src/main/java/com/limelight/grid/assets/MemoryAssetLoader.java @@ -1,74 +1,74 @@ -package com.limelight.grid.assets; - -import android.util.LruCache; - -import com.limelight.LimeLog; - -import java.lang.ref.SoftReference; -import java.util.HashMap; - -public class MemoryAssetLoader { - private static final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024); - private static final LruCache memoryCache = new LruCache(maxMemory / 16) { - @Override - protected int sizeOf(String key, ScaledBitmap bitmap) { - // Sizeof returns kilobytes - return bitmap.bitmap.getByteCount() / 1024; - } - - @Override - protected void entryRemoved(boolean evicted, String key, ScaledBitmap oldValue, ScaledBitmap newValue) { - super.entryRemoved(evicted, key, oldValue, newValue); - - if (evicted) { - // Keep a soft reference around to the bitmap as long as we can - evictionCache.put(key, new SoftReference<>(oldValue)); - } - } - }; - private static final HashMap> evictionCache = new HashMap<>(); - - private static String constructKey(CachedAppAssetLoader.LoaderTuple tuple) { - return tuple.computer.uuid+"-"+tuple.app.getAppId(); - } - - public ScaledBitmap loadBitmapFromCache(CachedAppAssetLoader.LoaderTuple tuple) { - final String key = constructKey(tuple); - - ScaledBitmap bmp = memoryCache.get(key); - if (bmp != null) { - LimeLog.info("LRU cache hit for tuple: "+tuple); - return bmp; - } - - SoftReference bmpRef = evictionCache.get(key); - if (bmpRef != null) { - bmp = bmpRef.get(); - if (bmp != null) { - LimeLog.info("Eviction cache hit for tuple: "+tuple); - - // Put this entry back into the LRU cache - evictionCache.remove(key); - memoryCache.put(key, bmp); - - return bmp; - } - else { - // The data is gone, so remove the dangling SoftReference now - evictionCache.remove(key); - } - } - - return null; - } - - public void populateCache(CachedAppAssetLoader.LoaderTuple tuple, ScaledBitmap bitmap) { - memoryCache.put(constructKey(tuple), bitmap); - } - - public void clearCache() { - // We must evict first because that will push all items into the eviction cache - memoryCache.evictAll(); - evictionCache.clear(); - } -} +package com.limelight.grid.assets; + +import android.util.LruCache; + +import com.limelight.LimeLog; + +import java.lang.ref.SoftReference; +import java.util.HashMap; + +public class MemoryAssetLoader { + private static final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024); + private static final LruCache memoryCache = new LruCache(maxMemory / 16) { + @Override + protected int sizeOf(String key, ScaledBitmap bitmap) { + // Sizeof returns kilobytes + return bitmap.bitmap.getByteCount() / 1024; + } + + @Override + protected void entryRemoved(boolean evicted, String key, ScaledBitmap oldValue, ScaledBitmap newValue) { + super.entryRemoved(evicted, key, oldValue, newValue); + + if (evicted) { + // Keep a soft reference around to the bitmap as long as we can + evictionCache.put(key, new SoftReference<>(oldValue)); + } + } + }; + private static final HashMap> evictionCache = new HashMap<>(); + + private static String constructKey(CachedAppAssetLoader.LoaderTuple tuple) { + return tuple.computer.uuid+"-"+tuple.app.getAppId(); + } + + public ScaledBitmap loadBitmapFromCache(CachedAppAssetLoader.LoaderTuple tuple) { + final String key = constructKey(tuple); + + ScaledBitmap bmp = memoryCache.get(key); + if (bmp != null) { + LimeLog.info("LRU cache hit for tuple: "+tuple); + return bmp; + } + + SoftReference bmpRef = evictionCache.get(key); + if (bmpRef != null) { + bmp = bmpRef.get(); + if (bmp != null) { + LimeLog.info("Eviction cache hit for tuple: "+tuple); + + // Put this entry back into the LRU cache + evictionCache.remove(key); + memoryCache.put(key, bmp); + + return bmp; + } + else { + // The data is gone, so remove the dangling SoftReference now + evictionCache.remove(key); + } + } + + return null; + } + + public void populateCache(CachedAppAssetLoader.LoaderTuple tuple, ScaledBitmap bitmap) { + memoryCache.put(constructKey(tuple), bitmap); + } + + public void clearCache() { + // We must evict first because that will push all items into the eviction cache + memoryCache.evictAll(); + evictionCache.clear(); + } +} diff --git a/app/src/main/java/com/limelight/grid/assets/NetworkAssetLoader.java b/app/src/main/java/com/limelight/grid/assets/NetworkAssetLoader.java old mode 100644 new mode 100755 index d75b4c5440..f0dc068311 --- a/app/src/main/java/com/limelight/grid/assets/NetworkAssetLoader.java +++ b/app/src/main/java/com/limelight/grid/assets/NetworkAssetLoader.java @@ -1,40 +1,40 @@ -package com.limelight.grid.assets; - -import android.content.Context; - -import com.limelight.LimeLog; -import com.limelight.binding.PlatformBinding; -import com.limelight.nvstream.http.NvHTTP; -import com.limelight.utils.ServerHelper; - -import java.io.IOException; -import java.io.InputStream; - -public class NetworkAssetLoader { - private final Context context; - private final String uniqueId; - - public NetworkAssetLoader(Context context, String uniqueId) { - this.context = context; - this.uniqueId = uniqueId; - } - - public InputStream getBitmapStream(CachedAppAssetLoader.LoaderTuple tuple) { - InputStream in = null; - try { - NvHTTP http = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(tuple.computer), - tuple.computer.httpsPort, uniqueId, tuple.computer.serverCert, - PlatformBinding.getCryptoProvider(context)); - in = http.getBoxArt(tuple.app); - } catch (IOException ignored) {} - - if (in != null) { - LimeLog.info("Network asset load complete: " + tuple); - } - else { - LimeLog.info("Network asset load failed: " + tuple); - } - - return in; - } -} +package com.limelight.grid.assets; + +import android.content.Context; + +import com.limelight.LimeLog; +import com.limelight.binding.PlatformBinding; +import com.limelight.nvstream.http.NvHTTP; +import com.limelight.utils.ServerHelper; + +import java.io.IOException; +import java.io.InputStream; + +public class NetworkAssetLoader { + private final Context context; + private final String uniqueId; + + public NetworkAssetLoader(Context context, String uniqueId) { + this.context = context; + this.uniqueId = uniqueId; + } + + public InputStream getBitmapStream(CachedAppAssetLoader.LoaderTuple tuple) { + InputStream in = null; + try { + NvHTTP http = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(tuple.computer), + tuple.computer.httpsPort, uniqueId, tuple.computer.serverCert, + PlatformBinding.getCryptoProvider(context)); + in = http.getBoxArt(tuple.app); + } catch (IOException ignored) {} + + if (in != null) { + LimeLog.info("Network asset load complete: " + tuple); + } + else { + LimeLog.info("Network asset load failed: " + tuple); + } + + return in; + } +} diff --git a/app/src/main/java/com/limelight/grid/assets/ScaledBitmap.java b/app/src/main/java/com/limelight/grid/assets/ScaledBitmap.java old mode 100644 new mode 100755 index dbf9f0f4d5..39a048825a --- a/app/src/main/java/com/limelight/grid/assets/ScaledBitmap.java +++ b/app/src/main/java/com/limelight/grid/assets/ScaledBitmap.java @@ -1,18 +1,18 @@ -package com.limelight.grid.assets; - -import android.graphics.Bitmap; - -public class ScaledBitmap { - public int originalWidth; - public int originalHeight; - - public Bitmap bitmap; - - public ScaledBitmap() {} - - public ScaledBitmap(int originalWidth, int originalHeight, Bitmap bitmap) { - this.originalWidth = originalWidth; - this.originalHeight = originalHeight; - this.bitmap = bitmap; - } -} +package com.limelight.grid.assets; + +import android.graphics.Bitmap; + +public class ScaledBitmap { + public int originalWidth; + public int originalHeight; + + public Bitmap bitmap; + + public ScaledBitmap() {} + + public ScaledBitmap(int originalWidth, int originalHeight, Bitmap bitmap) { + this.originalWidth = originalWidth; + this.originalHeight = originalHeight; + this.bitmap = bitmap; + } +} diff --git a/app/src/main/java/com/limelight/nvstream/ConnectionContext.java b/app/src/main/java/com/limelight/nvstream/ConnectionContext.java old mode 100644 new mode 100755 index d6b9224273..aa7795bde3 --- a/app/src/main/java/com/limelight/nvstream/ConnectionContext.java +++ b/app/src/main/java/com/limelight/nvstream/ConnectionContext.java @@ -1,34 +1,34 @@ -package com.limelight.nvstream; - -import com.limelight.nvstream.http.ComputerDetails; - -import java.security.cert.X509Certificate; - -import javax.crypto.SecretKey; - -public class ConnectionContext { - public ComputerDetails.AddressTuple serverAddress; - public int httpsPort; - public boolean isNvidiaServerSoftware; - public X509Certificate serverCert; - public StreamConfiguration streamConfig; - public NvConnectionListener connListener; - public SecretKey riKey; - public int riKeyId; - - // This is the version quad from the appversion tag of /serverinfo - public String serverAppVersion; - public String serverGfeVersion; - public int serverCodecModeSupport; - - // This is the sessionUrl0 tag from /resume and /launch - public String rtspSessionUrl; - - public int negotiatedWidth, negotiatedHeight; - public boolean negotiatedHdr; - - public int negotiatedRemoteStreaming; - public int negotiatedPacketSize; - - public int videoCapabilities; -} +package com.limelight.nvstream; + +import com.limelight.nvstream.http.ComputerDetails; + +import java.security.cert.X509Certificate; + +import javax.crypto.SecretKey; + +public class ConnectionContext { + public ComputerDetails.AddressTuple serverAddress; + public int httpsPort; + public boolean isNvidiaServerSoftware; + public X509Certificate serverCert; + public StreamConfiguration streamConfig; + public NvConnectionListener connListener; + public SecretKey riKey; + public int riKeyId; + + // This is the version quad from the appversion tag of /serverinfo + public String serverAppVersion; + public String serverGfeVersion; + public int serverCodecModeSupport; + + // This is the sessionUrl0 tag from /resume and /launch + public String rtspSessionUrl; + + public int negotiatedWidth, negotiatedHeight; + public boolean negotiatedHdr; + + public int negotiatedRemoteStreaming; + public int negotiatedPacketSize; + + public int videoCapabilities; +} diff --git a/app/src/main/java/com/limelight/nvstream/NvConnection.java b/app/src/main/java/com/limelight/nvstream/NvConnection.java old mode 100644 new mode 100755 index f29563f1c1..d78ab87497 --- a/app/src/main/java/com/limelight/nvstream/NvConnection.java +++ b/app/src/main/java/com/limelight/nvstream/NvConnection.java @@ -1,591 +1,598 @@ -package com.limelight.nvstream; - -import android.app.ActivityManager; -import android.content.Context; -import android.net.ConnectivityManager; -import android.net.IpPrefix; -import android.net.LinkProperties; -import android.net.Network; -import android.net.NetworkCapabilities; -import android.net.NetworkInfo; -import android.net.RouteInfo; -import android.os.Build; - -import java.io.IOException; -import java.net.InetAddress; -import java.net.InetSocketAddress; -import java.net.Socket; -import java.nio.ByteBuffer; -import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; -import java.security.cert.X509Certificate; -import java.util.Timer; -import java.util.TimerTask; -import java.util.concurrent.Semaphore; - -import javax.crypto.KeyGenerator; -import javax.crypto.SecretKey; - -import org.xmlpull.v1.XmlPullParserException; - -import com.limelight.LimeLog; -import com.limelight.nvstream.av.audio.AudioRenderer; -import com.limelight.nvstream.av.video.VideoDecoderRenderer; -import com.limelight.nvstream.http.ComputerDetails; -import com.limelight.nvstream.http.HostHttpResponseException; -import com.limelight.nvstream.http.LimelightCryptoProvider; -import com.limelight.nvstream.http.NvApp; -import com.limelight.nvstream.http.NvHTTP; -import com.limelight.nvstream.http.PairingManager; -import com.limelight.nvstream.input.MouseButtonPacket; -import com.limelight.nvstream.jni.MoonBridge; - -public class NvConnection { - // Context parameters - private LimelightCryptoProvider cryptoProvider; - private String uniqueId; - private ConnectionContext context; - private static Semaphore connectionAllowed = new Semaphore(1); - private final boolean isMonkey; - private final Context appContext; - - public NvConnection(Context appContext, ComputerDetails.AddressTuple host, int httpsPort, String uniqueId, StreamConfiguration config, LimelightCryptoProvider cryptoProvider, X509Certificate serverCert) - { - this.appContext = appContext; - this.cryptoProvider = cryptoProvider; - this.uniqueId = uniqueId; - - this.context = new ConnectionContext(); - this.context.serverAddress = host; - this.context.httpsPort = httpsPort; - this.context.streamConfig = config; - this.context.serverCert = serverCert; - - // This is unique per connection - this.context.riKey = generateRiAesKey(); - this.context.riKeyId = generateRiKeyId(); - - this.isMonkey = ActivityManager.isUserAMonkey(); - } - - private static SecretKey generateRiAesKey() { - try { - KeyGenerator keyGen = KeyGenerator.getInstance("AES"); - - // RI keys are 128 bits - keyGen.init(128); - - return keyGen.generateKey(); - } catch (NoSuchAlgorithmException e) { - e.printStackTrace(); - throw new RuntimeException(e); - } - } - - private static int generateRiKeyId() { - return new SecureRandom().nextInt(); - } - - public void stop() { - // Interrupt any pending connection. This is thread-safe. - MoonBridge.interruptConnection(); - - // Moonlight-core is not thread-safe with respect to connection start and stop, so - // we must not invoke that functionality in parallel. - synchronized (MoonBridge.class) { - MoonBridge.stopConnection(); - MoonBridge.cleanupBridge(); - } - - // Now a pending connection can be processed - connectionAllowed.release(); - } - - private InetAddress resolveServerAddress() throws IOException { - // Try to find an address that works for this host - InetAddress[] addrs = InetAddress.getAllByName(context.serverAddress.address); - for (InetAddress addr : addrs) { - try (Socket s = new Socket()) { - s.setSoLinger(true, 0); - s.connect(new InetSocketAddress(addr, context.serverAddress.port), 1000); - return addr; - } catch (IOException e) { - e.printStackTrace(); - } - } - - // If we made it here, we didn't manage to find a working address. If DNS returned any - // address, we'll use the first available address and hope for the best. - if (addrs.length > 0) { - return addrs[0]; - } - else { - throw new IOException("No addresses found for "+context.serverAddress); - } - } - - private int detectServerConnectionType() { - ConnectivityManager connMgr = (ConnectivityManager) appContext.getSystemService(Context.CONNECTIVITY_SERVICE); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - Network activeNetwork = connMgr.getActiveNetwork(); - if (activeNetwork != null) { - NetworkCapabilities netCaps = connMgr.getNetworkCapabilities(activeNetwork); - if (netCaps != null) { - if (netCaps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) || - !netCaps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)) { - // VPNs are treated as remote connections - return StreamConfiguration.STREAM_CFG_REMOTE; - } - else if (netCaps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) { - // Cellular is always treated as remote to avoid any possible - // issues with 464XLAT or similar technologies. - return StreamConfiguration.STREAM_CFG_REMOTE; - } - } - - // Check if the server address is on-link - LinkProperties linkProperties = connMgr.getLinkProperties(activeNetwork); - if (linkProperties != null) { - InetAddress serverAddress; - try { - serverAddress = resolveServerAddress(); - } catch (IOException e) { - e.printStackTrace(); - - // We can't decide without being able to resolve the server address - return StreamConfiguration.STREAM_CFG_AUTO; - } - - // If the address is in the NAT64 prefix, always treat it as remote - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - IpPrefix nat64Prefix = linkProperties.getNat64Prefix(); - if (nat64Prefix != null && nat64Prefix.contains(serverAddress)) { - return StreamConfiguration.STREAM_CFG_REMOTE; - } - } - - for (RouteInfo route : linkProperties.getRoutes()) { - // Skip non-unicast routes (which are all we get prior to Android 13) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && route.getType() != RouteInfo.RTN_UNICAST) { - continue; - } - - // Find the first route that matches this address - if (route.matches(serverAddress)) { - // If there's no gateway, this is an on-link destination - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - // We want to use hasGateway() because getGateway() doesn't adhere - // to documented behavior of returning null for on-link addresses. - if (!route.hasGateway()) { - return StreamConfiguration.STREAM_CFG_LOCAL; - } - } - else { - // getGateway() is documented to return null for on-link destinations, - // but it actually returns the unspecified address (0.0.0.0 or ::). - InetAddress gateway = route.getGateway(); - if (gateway == null || gateway.isAnyLocalAddress()) { - return StreamConfiguration.STREAM_CFG_LOCAL; - } - } - - // We _should_ stop after the first matching route, but for some reason - // Android doesn't always report IPv6 routes in descending order of - // specificity and metric. To handle that case, we enumerate all matching - // routes, assuming that an on-link route will always be preferred. - } - } - } - } - } - else { - NetworkInfo activeNetworkInfo = connMgr.getActiveNetworkInfo(); - if (activeNetworkInfo != null) { - switch (activeNetworkInfo.getType()) { - case ConnectivityManager.TYPE_VPN: - case ConnectivityManager.TYPE_MOBILE: - case ConnectivityManager.TYPE_MOBILE_DUN: - case ConnectivityManager.TYPE_MOBILE_HIPRI: - case ConnectivityManager.TYPE_MOBILE_MMS: - case ConnectivityManager.TYPE_MOBILE_SUPL: - case ConnectivityManager.TYPE_WIMAX: - // VPNs and cellular connections are always remote connections - return StreamConfiguration.STREAM_CFG_REMOTE; - } - } - } - - // If we can't determine the connection type, let moonlight-common-c decide. - return StreamConfiguration.STREAM_CFG_AUTO; - } - - private boolean startApp() throws XmlPullParserException, IOException - { - NvHTTP h = new NvHTTP(context.serverAddress, context.httpsPort, uniqueId, context.serverCert, cryptoProvider); - - String serverInfo = h.getServerInfo(true); - - context.serverAppVersion = h.getServerVersion(serverInfo); - if (context.serverAppVersion == null) { - context.connListener.displayMessage("Server version malformed"); - return false; - } - - ComputerDetails details = h.getComputerDetails(serverInfo); - context.isNvidiaServerSoftware = details.nvidiaServer; - - // May be missing for older servers - context.serverGfeVersion = h.getGfeVersion(serverInfo); - - if (h.getPairState(serverInfo) != PairingManager.PairState.PAIRED) { - context.connListener.displayMessage("Device not paired with computer"); - return false; - } - - context.serverCodecModeSupport = (int)h.getServerCodecModeSupport(serverInfo); - - context.negotiatedHdr = (context.streamConfig.getSupportedVideoFormats() & MoonBridge.VIDEO_FORMAT_MASK_10BIT) != 0; - if ((context.serverCodecModeSupport & 0x20200) == 0 && context.negotiatedHdr) { - context.connListener.displayTransientMessage("Your PC GPU does not support streaming HDR. The stream will be SDR."); - context.negotiatedHdr = false; - } - - // - // Decide on negotiated stream parameters now - // - - // Check for a supported stream resolution - if ((context.streamConfig.getWidth() > 4096 || context.streamConfig.getHeight() > 4096) && - (h.getServerCodecModeSupport(serverInfo) & 0x200) == 0 && context.isNvidiaServerSoftware) { - context.connListener.displayMessage("Your host PC does not support streaming at resolutions above 4K."); - return false; - } - else if ((context.streamConfig.getWidth() > 4096 || context.streamConfig.getHeight() > 4096) && - (context.streamConfig.getSupportedVideoFormats() & ~MoonBridge.VIDEO_FORMAT_MASK_H264) == 0) { - context.connListener.displayMessage("Your streaming device must support HEVC or AV1 to stream at resolutions above 4K."); - return false; - } - else if (context.streamConfig.getHeight() >= 2160 && !h.supports4K(serverInfo)) { - // Client wants 4K but the server can't do it - context.connListener.displayTransientMessage("You must update GeForce Experience to stream in 4K. The stream will be 1080p."); - - // Lower resolution to 1080p - context.negotiatedWidth = 1920; - context.negotiatedHeight = 1080; - } - else { - // Take what the client wanted - context.negotiatedWidth = context.streamConfig.getWidth(); - context.negotiatedHeight = context.streamConfig.getHeight(); - } - - // We will perform some connection type detection if the caller asked for it - if (context.streamConfig.getRemote() == StreamConfiguration.STREAM_CFG_AUTO) { - context.negotiatedRemoteStreaming = detectServerConnectionType(); - context.negotiatedPacketSize = - context.negotiatedRemoteStreaming == StreamConfiguration.STREAM_CFG_REMOTE ? - 1024 : context.streamConfig.getMaxPacketSize(); - } - else { - context.negotiatedRemoteStreaming = context.streamConfig.getRemote(); - context.negotiatedPacketSize = context.streamConfig.getMaxPacketSize(); - } - - // - // Video stream format will be decided during the RTSP handshake - // - - NvApp app = context.streamConfig.getApp(); - - // If the client did not provide an exact app ID, do a lookup with the applist - if (!context.streamConfig.getApp().isInitialized()) { - LimeLog.info("Using deprecated app lookup method - Please specify an app ID in your StreamConfiguration instead"); - app = h.getAppByName(context.streamConfig.getApp().getAppName()); - if (app == null) { - context.connListener.displayMessage("The app " + context.streamConfig.getApp().getAppName() + " is not in GFE app list"); - return false; - } - } - - // If there's a game running, resume it - if (h.getCurrentGame(serverInfo) != 0) { - try { - if (h.getCurrentGame(serverInfo) == app.getAppId()) { - if (!h.launchApp(context, "resume", app.getAppId(), context.negotiatedHdr)) { - context.connListener.displayMessage("Failed to resume existing session"); - return false; - } - } else { - return quitAndLaunch(h, context); - } - } catch (HostHttpResponseException e) { - if (e.getErrorCode() == 470) { - // This is the error you get when you try to resume a session that's not yours. - // Because this is fairly common, we'll display a more detailed message. - context.connListener.displayMessage("This session wasn't started by this device," + - " so it cannot be resumed. End streaming on the original " + - "device or the PC itself and try again. (Error code: "+e.getErrorCode()+")"); - return false; - } - else if (e.getErrorCode() == 525) { - context.connListener.displayMessage("The application is minimized. Resume it on the PC manually or " + - "quit the session and start streaming again."); - return false; - } else { - throw e; - } - } - - LimeLog.info("Resumed existing game session"); - return true; - } - else { - return launchNotRunningApp(h, context); - } - } - - protected boolean quitAndLaunch(NvHTTP h, ConnectionContext context) throws IOException, - XmlPullParserException { - try { - if (!h.quitApp()) { - context.connListener.displayMessage("Failed to quit previous session! You must quit it manually"); - return false; - } - } catch (HostHttpResponseException e) { - if (e.getErrorCode() == 599) { - context.connListener.displayMessage("This session wasn't started by this device," + - " so it cannot be quit. End streaming on the original " + - "device or the PC itself. (Error code: "+e.getErrorCode()+")"); - return false; - } - else { - throw e; - } - } - - return launchNotRunningApp(h, context); - } - - private boolean launchNotRunningApp(NvHTTP h, ConnectionContext context) - throws IOException, XmlPullParserException { - // Launch the app since it's not running - if (!h.launchApp(context, "launch", context.streamConfig.getApp().getAppId(), context.negotiatedHdr)) { - context.connListener.displayMessage("Failed to launch application"); - return false; - } - - LimeLog.info("Launched new game session"); - - return true; - } - - public void start(final AudioRenderer audioRenderer, final VideoDecoderRenderer videoDecoderRenderer, final NvConnectionListener connectionListener) - { - new Thread(new Runnable() { - public void run() { - context.connListener = connectionListener; - context.videoCapabilities = videoDecoderRenderer.getCapabilities(); - - String appName = context.streamConfig.getApp().getAppName(); - - context.connListener.stageStarting(appName); - - try { - if (!startApp()) { - context.connListener.stageFailed(appName, 0, 0); - return; - } - context.connListener.stageComplete(appName); - } catch (HostHttpResponseException e) { - e.printStackTrace(); - context.connListener.displayMessage(e.getMessage()); - context.connListener.stageFailed(appName, 0, e.getErrorCode()); - return; - } catch (XmlPullParserException | IOException e) { - e.printStackTrace(); - context.connListener.displayMessage(e.getMessage()); - context.connListener.stageFailed(appName, MoonBridge.ML_PORT_FLAG_TCP_47984 | MoonBridge.ML_PORT_FLAG_TCP_47989, 0); - return; - } - - ByteBuffer ib = ByteBuffer.allocate(16); - ib.putInt(context.riKeyId); - - // Acquire the connection semaphore to ensure we only have one - // connection going at once. - try { - connectionAllowed.acquire(); - } catch (InterruptedException e) { - context.connListener.displayMessage(e.getMessage()); - context.connListener.stageFailed(appName, 0, 0); - return; - } - - // Moonlight-core is not thread-safe with respect to connection start and stop, so - // we must not invoke that functionality in parallel. - synchronized (MoonBridge.class) { - MoonBridge.setupBridge(videoDecoderRenderer, audioRenderer, connectionListener); - int ret = MoonBridge.startConnection(context.serverAddress.address, - context.serverAppVersion, context.serverGfeVersion, context.rtspSessionUrl, - context.serverCodecModeSupport, - context.negotiatedWidth, context.negotiatedHeight, - context.streamConfig.getRefreshRate(), context.streamConfig.getBitrate(), - context.negotiatedPacketSize, context.negotiatedRemoteStreaming, - context.streamConfig.getAudioConfiguration().toInt(), - context.streamConfig.getSupportedVideoFormats(), - context.streamConfig.getClientRefreshRateX100(), - context.riKey.getEncoded(), ib.array(), - context.videoCapabilities, - context.streamConfig.getColorSpace(), - context.streamConfig.getColorRange()); - if (ret != 0) { - // LiStartConnection() failed, so the caller is not expected - // to stop the connection themselves. We need to release their - // semaphore count for them. - connectionAllowed.release(); - return; - } - } - } - }).start(); - } - - public void sendMouseMove(final short deltaX, final short deltaY) - { - if (!isMonkey) { - MoonBridge.sendMouseMove(deltaX, deltaY); - } - } - - public void sendMousePosition(short x, short y, short referenceWidth, short referenceHeight) - { - if (!isMonkey) { - MoonBridge.sendMousePosition(x, y, referenceWidth, referenceHeight); - } - } - - public void sendMouseMoveAsMousePosition(short deltaX, short deltaY, short referenceWidth, short referenceHeight) - { - if (!isMonkey) { - MoonBridge.sendMouseMoveAsMousePosition(deltaX, deltaY, referenceWidth, referenceHeight); - } - } - - public void sendMouseButtonDown(final byte mouseButton) - { - if (!isMonkey) { - MoonBridge.sendMouseButton(MouseButtonPacket.PRESS_EVENT, mouseButton); - } - } - - public void sendMouseButtonUp(final byte mouseButton) - { - if (!isMonkey) { - MoonBridge.sendMouseButton(MouseButtonPacket.RELEASE_EVENT, mouseButton); - } - } - - public void sendControllerInput(final short controllerNumber, - final short activeGamepadMask, final int buttonFlags, - final byte leftTrigger, final byte rightTrigger, - final short leftStickX, final short leftStickY, - final short rightStickX, final short rightStickY) - { - if (!isMonkey) { - MoonBridge.sendMultiControllerInput(controllerNumber, activeGamepadMask, buttonFlags, - leftTrigger, rightTrigger, leftStickX, leftStickY, rightStickX, rightStickY); - } - } - - public void sendKeyboardInput(final short keyMap, final byte keyDirection, final byte modifier, final byte flags) { - if (!isMonkey) { - MoonBridge.sendKeyboardInput(keyMap, keyDirection, modifier, flags); - } - } - - public void sendMouseScroll(final byte scrollClicks) { - if (!isMonkey) { - MoonBridge.sendMouseHighResScroll((short)(scrollClicks * 120)); // WHEEL_DELTA - } - } - - public void sendMouseHScroll(final byte scrollClicks) { - if (!isMonkey) { - MoonBridge.sendMouseHighResHScroll((short)(scrollClicks * 120)); // WHEEL_DELTA - } - } - - public void sendMouseHighResScroll(final short scrollAmount) { - if (!isMonkey) { - MoonBridge.sendMouseHighResScroll(scrollAmount); - } - } - - public void sendMouseHighResHScroll(final short scrollAmount) { - if (!isMonkey) { - MoonBridge.sendMouseHighResHScroll(scrollAmount); - } - } - - public int sendTouchEvent(byte eventType, int pointerId, float x, float y, float pressureOrDistance, - float contactAreaMajor, float contactAreaMinor, short rotation) { - if (!isMonkey) { - return MoonBridge.sendTouchEvent(eventType, pointerId, x, y, pressureOrDistance, - contactAreaMajor, contactAreaMinor, rotation); - } - else { - return MoonBridge.LI_ERR_UNSUPPORTED; - } - } - - public int sendPenEvent(byte eventType, byte toolType, byte penButtons, float x, float y, - float pressureOrDistance, float contactAreaMajor, float contactAreaMinor, - short rotation, byte tilt) { - if (!isMonkey) { - return MoonBridge.sendPenEvent(eventType, toolType, penButtons, x, y, pressureOrDistance, - contactAreaMajor, contactAreaMinor, rotation, tilt); - } - else { - return MoonBridge.LI_ERR_UNSUPPORTED; - } - } - - public int sendControllerArrivalEvent(byte controllerNumber, short activeGamepadMask, byte type, - int supportedButtonFlags, short capabilities) { - return MoonBridge.sendControllerArrivalEvent(controllerNumber, activeGamepadMask, type, supportedButtonFlags, capabilities); - } - - public int sendControllerTouchEvent(byte controllerNumber, byte eventType, int pointerId, - float x, float y, float pressure) { - if (!isMonkey) { - return MoonBridge.sendControllerTouchEvent(controllerNumber, eventType, pointerId, x, y, pressure); - } - else { - return MoonBridge.LI_ERR_UNSUPPORTED; - } - } - - public int sendControllerMotionEvent(byte controllerNumber, byte motionType, - float x, float y, float z) { - if (!isMonkey) { - return MoonBridge.sendControllerMotionEvent(controllerNumber, motionType, x, y, z); - } - else { - return MoonBridge.LI_ERR_UNSUPPORTED; - } - } - - public void sendControllerBatteryEvent(byte controllerNumber, byte batteryState, byte batteryPercentage) { - MoonBridge.sendControllerBatteryEvent(controllerNumber, batteryState, batteryPercentage); - } - - public void sendUtf8Text(final String text) { - if (!isMonkey) { - MoonBridge.sendUtf8Text(text); - } - } - - public static String findExternalAddressForMdns(String stunHostname, int stunPort) { - return MoonBridge.findExternalAddressIP4(stunHostname, stunPort); - } -} +package com.limelight.nvstream; + +import android.app.ActivityManager; +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.IpPrefix; +import android.net.LinkProperties; +import android.net.Network; +import android.net.NetworkCapabilities; +import android.net.NetworkInfo; +import android.net.RouteInfo; +import android.os.Build; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.nio.ByteBuffer; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.security.cert.X509Certificate; +import java.util.Timer; +import java.util.TimerTask; +import java.util.concurrent.Semaphore; + +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; + +import org.xmlpull.v1.XmlPullParserException; + +import com.limelight.LimeLog; +import com.limelight.nvstream.av.audio.AudioRenderer; +import com.limelight.nvstream.av.video.VideoDecoderRenderer; +import com.limelight.nvstream.http.ComputerDetails; +import com.limelight.nvstream.http.HostHttpResponseException; +import com.limelight.nvstream.http.LimelightCryptoProvider; +import com.limelight.nvstream.http.NvApp; +import com.limelight.nvstream.http.NvHTTP; +import com.limelight.nvstream.http.PairingManager; +import com.limelight.nvstream.input.MouseButtonPacket; +import com.limelight.nvstream.jni.MoonBridge; + +public class NvConnection { + // Context parameters + private LimelightCryptoProvider cryptoProvider; + private String uniqueId; + private ConnectionContext context; + private static Semaphore connectionAllowed = new Semaphore(1); + private final boolean isMonkey; + private final Context appContext; + + public NvConnection(Context appContext, ComputerDetails.AddressTuple host, int httpsPort, String uniqueId, StreamConfiguration config, LimelightCryptoProvider cryptoProvider, X509Certificate serverCert) + { + this.appContext = appContext; + this.cryptoProvider = cryptoProvider; + this.uniqueId = uniqueId; + + this.context = new ConnectionContext(); + this.context.serverAddress = host; + this.context.httpsPort = httpsPort; + this.context.streamConfig = config; + this.context.serverCert = serverCert; + + // This is unique per connection + this.context.riKey = generateRiAesKey(); + this.context.riKeyId = generateRiKeyId(); + + this.isMonkey = ActivityManager.isUserAMonkey(); + } + + private static SecretKey generateRiAesKey() { + try { + KeyGenerator keyGen = KeyGenerator.getInstance("AES"); + + // RI keys are 128 bits + keyGen.init(128); + + return keyGen.generateKey(); + } catch (NoSuchAlgorithmException e) { + e.printStackTrace(); + throw new RuntimeException(e); + } + } + + private static int generateRiKeyId() { + return new SecureRandom().nextInt(); + } + + public void stop() { + // Interrupt any pending connection. This is thread-safe. + MoonBridge.interruptConnection(); + + // Moonlight-core is not thread-safe with respect to connection start and stop, so + // we must not invoke that functionality in parallel. + synchronized (MoonBridge.class) { + MoonBridge.stopConnection(); + MoonBridge.cleanupBridge(); + } + + // Now a pending connection can be processed + connectionAllowed.release(); + } + + private InetAddress resolveServerAddress() throws IOException { + // Try to find an address that works for this host + InetAddress[] addrs = InetAddress.getAllByName(context.serverAddress.address); + for (InetAddress addr : addrs) { + try (Socket s = new Socket()) { + s.setSoLinger(true, 0); + s.connect(new InetSocketAddress(addr, context.serverAddress.port), 1000); + return addr; + } catch (IOException e) { + e.printStackTrace(); + } + } + + // If we made it here, we didn't manage to find a working address. If DNS returned any + // address, we'll use the first available address and hope for the best. + if (addrs.length > 0) { + return addrs[0]; + } + else { + throw new IOException("No addresses found for "+context.serverAddress); + } + } + + private int detectServerConnectionType() { + ConnectivityManager connMgr = (ConnectivityManager) appContext.getSystemService(Context.CONNECTIVITY_SERVICE); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + Network activeNetwork = connMgr.getActiveNetwork(); + if (activeNetwork != null) { + NetworkCapabilities netCaps = connMgr.getNetworkCapabilities(activeNetwork); + if (netCaps != null) { + if (netCaps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) || + !netCaps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)) { + // VPNs are treated as remote connections + return StreamConfiguration.STREAM_CFG_REMOTE; + } + else if (netCaps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) { + // Cellular is always treated as remote to avoid any possible + // issues with 464XLAT or similar technologies. + return StreamConfiguration.STREAM_CFG_REMOTE; + } + } + + // Check if the server address is on-link + LinkProperties linkProperties = connMgr.getLinkProperties(activeNetwork); + if (linkProperties != null) { + InetAddress serverAddress; + try { + serverAddress = resolveServerAddress(); + } catch (IOException e) { + e.printStackTrace(); + + // We can't decide without being able to resolve the server address + return StreamConfiguration.STREAM_CFG_AUTO; + } + + // If the address is in the NAT64 prefix, always treat it as remote + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + IpPrefix nat64Prefix = linkProperties.getNat64Prefix(); + if (nat64Prefix != null && nat64Prefix.contains(serverAddress)) { + return StreamConfiguration.STREAM_CFG_REMOTE; + } + } + + for (RouteInfo route : linkProperties.getRoutes()) { + // Skip non-unicast routes (which are all we get prior to Android 13) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && route.getType() != RouteInfo.RTN_UNICAST) { + continue; + } + + // Find the first route that matches this address + if (route.matches(serverAddress)) { + // If there's no gateway, this is an on-link destination + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // We want to use hasGateway() because getGateway() doesn't adhere + // to documented behavior of returning null for on-link addresses. + if (!route.hasGateway()) { + return StreamConfiguration.STREAM_CFG_LOCAL; + } + } + else { + // getGateway() is documented to return null for on-link destinations, + // but it actually returns the unspecified address (0.0.0.0 or ::). + InetAddress gateway = route.getGateway(); + if (gateway == null || gateway.isAnyLocalAddress()) { + return StreamConfiguration.STREAM_CFG_LOCAL; + } + } + + // We _should_ stop after the first matching route, but for some reason + // Android doesn't always report IPv6 routes in descending order of + // specificity and metric. To handle that case, we enumerate all matching + // routes, assuming that an on-link route will always be preferred. + } + } + } + } + } + else { + NetworkInfo activeNetworkInfo = connMgr.getActiveNetworkInfo(); + if (activeNetworkInfo != null) { + switch (activeNetworkInfo.getType()) { + case ConnectivityManager.TYPE_VPN: + case ConnectivityManager.TYPE_MOBILE: + case ConnectivityManager.TYPE_MOBILE_DUN: + case ConnectivityManager.TYPE_MOBILE_HIPRI: + case ConnectivityManager.TYPE_MOBILE_MMS: + case ConnectivityManager.TYPE_MOBILE_SUPL: + case ConnectivityManager.TYPE_WIMAX: + // VPNs and cellular connections are always remote connections + return StreamConfiguration.STREAM_CFG_REMOTE; + } + } + } + + // If we can't determine the connection type, let moonlight-common-c decide. + return StreamConfiguration.STREAM_CFG_AUTO; + } + + private boolean startApp() throws XmlPullParserException, IOException + { + NvHTTP h = new NvHTTP(context.serverAddress, context.httpsPort, uniqueId, context.serverCert, cryptoProvider); + + String serverInfo = h.getServerInfo(true); + + context.serverAppVersion = h.getServerVersion(serverInfo); + if (context.serverAppVersion == null) { + context.connListener.displayMessage("Server version malformed"); + return false; + } + + ComputerDetails details = h.getComputerDetails(serverInfo); + context.isNvidiaServerSoftware = details.nvidiaServer; + + // May be missing for older servers + context.serverGfeVersion = h.getGfeVersion(serverInfo); + + if (h.getPairState(serverInfo) != PairingManager.PairState.PAIRED) { + context.connListener.displayMessage("Device not paired with computer"); + return false; + } + + context.serverCodecModeSupport = (int)h.getServerCodecModeSupport(serverInfo); + + context.negotiatedHdr = (context.streamConfig.getSupportedVideoFormats() & MoonBridge.VIDEO_FORMAT_MASK_10BIT) != 0; + if ((context.serverCodecModeSupport & 0x20200) == 0 && context.negotiatedHdr) { + context.connListener.displayTransientMessage("Your PC GPU does not support streaming HDR. The stream will be SDR."); + context.negotiatedHdr = false; + } + + // + // Decide on negotiated stream parameters now + // + + // Check for a supported stream resolution + if ((context.streamConfig.getWidth() > 4096 || context.streamConfig.getHeight() > 4096) && + (h.getServerCodecModeSupport(serverInfo) & 0x200) == 0 && context.isNvidiaServerSoftware) { + context.connListener.displayMessage("Your host PC does not support streaming at resolutions above 4K."); + return false; + } + else if ((context.streamConfig.getWidth() > 4096 || context.streamConfig.getHeight() > 4096) && + (context.streamConfig.getSupportedVideoFormats() & ~MoonBridge.VIDEO_FORMAT_MASK_H264) == 0) { + context.connListener.displayMessage("Your streaming device must support HEVC or AV1 to stream at resolutions above 4K."); + return false; + } + else if (context.streamConfig.getHeight() >= 2160 && !h.supports4K(serverInfo)) { + // Client wants 4K but the server can't do it + context.connListener.displayTransientMessage("You must update GeForce Experience to stream in 4K. The stream will be 1080p."); + + // Lower resolution to 1080p + context.negotiatedWidth = 1920; + context.negotiatedHeight = 1080; + } + else { + // Take what the client wanted + context.negotiatedWidth = context.streamConfig.getWidth(); + context.negotiatedHeight = context.streamConfig.getHeight(); + } + + // We will perform some connection type detection if the caller asked for it + if (context.streamConfig.getRemote() == StreamConfiguration.STREAM_CFG_AUTO) { + context.negotiatedRemoteStreaming = detectServerConnectionType(); + context.negotiatedPacketSize = + context.negotiatedRemoteStreaming == StreamConfiguration.STREAM_CFG_REMOTE ? + 1024 : context.streamConfig.getMaxPacketSize(); + } + else { + context.negotiatedRemoteStreaming = context.streamConfig.getRemote(); + context.negotiatedPacketSize = context.streamConfig.getMaxPacketSize(); + } + + // + // Video stream format will be decided during the RTSP handshake + // + + NvApp app = context.streamConfig.getApp(); + + // If the client did not provide an exact app ID, do a lookup with the applist + if (!context.streamConfig.getApp().isInitialized()) { + LimeLog.info("Using deprecated app lookup method - Please specify an app ID in your StreamConfiguration instead"); + app = h.getAppByName(context.streamConfig.getApp().getAppName()); + if (app == null) { + context.connListener.displayMessage("The app " + context.streamConfig.getApp().getAppName() + " is not in GFE app list"); + return false; + } + } + + // If there's a game running, resume it + if (h.getCurrentGame(serverInfo) != 0) { + try { + if (h.getCurrentGame(serverInfo) == app.getAppId()) { + if (!h.launchApp(context, "resume", app.getAppId(), context.negotiatedHdr)) { + context.connListener.displayMessage("Failed to resume existing session"); + return false; + } + } else { + return quitAndLaunch(h, context); + } + } catch (HostHttpResponseException e) { + if (e.getErrorCode() == 470) { + // This is the error you get when you try to resume a session that's not yours. + // Because this is fairly common, we'll display a more detailed message. + context.connListener.displayMessage("This session wasn't started by this device," + + " so it cannot be resumed. End streaming on the original " + + "device or the PC itself and try again. (Error code: "+e.getErrorCode()+")"); + return false; + } + else if (e.getErrorCode() == 525) { + context.connListener.displayMessage("The application is minimized. Resume it on the PC manually or " + + "quit the session and start streaming again."); + return false; + } else { + throw e; + } + } + + LimeLog.info("Resumed existing game session"); + return true; + } + else { + return launchNotRunningApp(h, context); + } + } + + protected boolean quitAndLaunch(NvHTTP h, ConnectionContext context) throws IOException, + XmlPullParserException { + try { + if (!h.quitApp()) { + context.connListener.displayMessage("Failed to quit previous session! You must quit it manually"); + return false; + } + } catch (HostHttpResponseException e) { + if (e.getErrorCode() == 599) { + context.connListener.displayMessage("This session wasn't started by this device," + + " so it cannot be quit. End streaming on the original " + + "device or the PC itself. (Error code: "+e.getErrorCode()+")"); + return false; + } + else { + throw e; + } + } + + return launchNotRunningApp(h, context); + } + + private boolean launchNotRunningApp(NvHTTP h, ConnectionContext context) + throws IOException, XmlPullParserException { + // Launch the app since it's not running + if (!h.launchApp(context, "launch", context.streamConfig.getApp().getAppId(), context.negotiatedHdr)) { + context.connListener.displayMessage("Failed to launch application"); + return false; + } + + LimeLog.info("Launched new game session"); + + return true; + } + + public void start(final AudioRenderer audioRenderer, final VideoDecoderRenderer videoDecoderRenderer, final NvConnectionListener connectionListener) + { + new Thread(new Runnable() { + public void run() { + context.connListener = connectionListener; + context.videoCapabilities = videoDecoderRenderer.getCapabilities(); + + String appName = context.streamConfig.getApp().getAppName(); + + context.connListener.stageStarting(appName); + + try { + if (!startApp()) { + context.connListener.stageFailed(appName, 0, 0); + return; + } + context.connListener.stageComplete(appName); + } catch (HostHttpResponseException e) { + e.printStackTrace(); + context.connListener.displayMessage(e.getMessage()); + context.connListener.stageFailed(appName, 0, e.getErrorCode()); + return; + } catch (XmlPullParserException | IOException e) { + e.printStackTrace(); + context.connListener.displayMessage(e.getMessage()); + context.connListener.stageFailed(appName, MoonBridge.ML_PORT_FLAG_TCP_47984 | MoonBridge.ML_PORT_FLAG_TCP_47989, 0); + return; + } + + ByteBuffer ib = ByteBuffer.allocate(16); + ib.putInt(context.riKeyId); + + // Acquire the connection semaphore to ensure we only have one + // connection going at once. + try { + connectionAllowed.acquire(); + } catch (InterruptedException e) { + context.connListener.displayMessage(e.getMessage()); + context.connListener.stageFailed(appName, 0, 0); + return; + } + + // Moonlight-core is not thread-safe with respect to connection start and stop, so + // we must not invoke that functionality in parallel. + synchronized (MoonBridge.class) { + MoonBridge.setupBridge(videoDecoderRenderer, audioRenderer, connectionListener); + int ret = MoonBridge.startConnection(context.serverAddress.address, + context.serverAppVersion, context.serverGfeVersion, context.rtspSessionUrl, + context.serverCodecModeSupport, + context.negotiatedWidth, context.negotiatedHeight, + context.streamConfig.getRefreshRate(), context.streamConfig.getBitrate(), + context.negotiatedPacketSize, context.negotiatedRemoteStreaming, + context.streamConfig.getAudioConfiguration().toInt(), + context.streamConfig.getSupportedVideoFormats(), + context.streamConfig.getClientRefreshRateX100(), + context.riKey.getEncoded(), ib.array(), + context.videoCapabilities, + context.streamConfig.getColorSpace(), + context.streamConfig.getColorRange()); + if (ret != 0) { + // LiStartConnection() failed, so the caller is not expected + // to stop the connection themselves. We need to release their + // semaphore count for them. + connectionAllowed.release(); + return; + } + } + } + }).start(); + } + + public void sendMouseMove(final short deltaX, final short deltaY) + { + LimeLog.info("sendMousePosition==1-"+deltaX+","+deltaY); + + if (!isMonkey) { + MoonBridge.sendMouseMove(deltaX, deltaY); + } + } + + public void sendMousePosition(short x, short y, short referenceWidth, short referenceHeight) + { + LimeLog.info("sendMousePosition==2"); + if (!isMonkey) { + MoonBridge.sendMousePosition(x, y, referenceWidth, referenceHeight); + } + } + + public void sendMouseMoveAsMousePosition(short deltaX, short deltaY, short referenceWidth, short referenceHeight) + { + LimeLog.info("sendMousePosition==3-"+deltaX); + + if (!isMonkey) { + MoonBridge.sendMouseMoveAsMousePosition(deltaX, deltaY, referenceWidth, referenceHeight); + } + } + + public void sendMouseButtonDown(final byte mouseButton) + { + LimeLog.info("sendMousePosition==4"); + if (!isMonkey) { + MoonBridge.sendMouseButton(MouseButtonPacket.PRESS_EVENT, mouseButton); + } + } + + public void sendMouseButtonUp(final byte mouseButton) + { + LimeLog.info("sendMousePosition==5"); + if (!isMonkey) { + MoonBridge.sendMouseButton(MouseButtonPacket.RELEASE_EVENT, mouseButton); + } + } + + public void sendControllerInput(final short controllerNumber, + final short activeGamepadMask, final int buttonFlags, + final byte leftTrigger, final byte rightTrigger, + final short leftStickX, final short leftStickY, + final short rightStickX, final short rightStickY) + { + if (!isMonkey) { + MoonBridge.sendMultiControllerInput(controllerNumber, activeGamepadMask, buttonFlags, + leftTrigger, rightTrigger, leftStickX, leftStickY, rightStickX, rightStickY); + } + } + + public void sendKeyboardInput(final short keyMap, final byte keyDirection, final byte modifier, final byte flags) { + if (!isMonkey) { + MoonBridge.sendKeyboardInput(keyMap, keyDirection, modifier, flags); + } + } + + public void sendMouseScroll(final byte scrollClicks) { + if (!isMonkey) { + MoonBridge.sendMouseHighResScroll((short)(scrollClicks * 120)); // WHEEL_DELTA + } + } + + public void sendMouseHScroll(final byte scrollClicks) { + if (!isMonkey) { + MoonBridge.sendMouseHighResHScroll((short)(scrollClicks * 120)); // WHEEL_DELTA + } + } + + public void sendMouseHighResScroll(final short scrollAmount) { + if (!isMonkey) { + MoonBridge.sendMouseHighResScroll(scrollAmount); + } + } + + public void sendMouseHighResHScroll(final short scrollAmount) { + if (!isMonkey) { + MoonBridge.sendMouseHighResHScroll(scrollAmount); + } + } + + public int sendTouchEvent(byte eventType, int pointerId, float x, float y, float pressureOrDistance, + float contactAreaMajor, float contactAreaMinor, short rotation) { + if (!isMonkey) { + return MoonBridge.sendTouchEvent(eventType, pointerId, x, y, pressureOrDistance, + contactAreaMajor, contactAreaMinor, rotation); + } + else { + return MoonBridge.LI_ERR_UNSUPPORTED; + } + } + + public int sendPenEvent(byte eventType, byte toolType, byte penButtons, float x, float y, + float pressureOrDistance, float contactAreaMajor, float contactAreaMinor, + short rotation, byte tilt) { + if (!isMonkey) { + return MoonBridge.sendPenEvent(eventType, toolType, penButtons, x, y, pressureOrDistance, + contactAreaMajor, contactAreaMinor, rotation, tilt); + } + else { + return MoonBridge.LI_ERR_UNSUPPORTED; + } + } + + public int sendControllerArrivalEvent(byte controllerNumber, short activeGamepadMask, byte type, + int supportedButtonFlags, short capabilities) { + return MoonBridge.sendControllerArrivalEvent(controllerNumber, activeGamepadMask, type, supportedButtonFlags, capabilities); + } + + public int sendControllerTouchEvent(byte controllerNumber, byte eventType, int pointerId, + float x, float y, float pressure) { + if (!isMonkey) { + return MoonBridge.sendControllerTouchEvent(controllerNumber, eventType, pointerId, x, y, pressure); + } + else { + return MoonBridge.LI_ERR_UNSUPPORTED; + } + } + + public int sendControllerMotionEvent(byte controllerNumber, byte motionType, + float x, float y, float z) { + if (!isMonkey) { + return MoonBridge.sendControllerMotionEvent(controllerNumber, motionType, x, y, z); + } + else { + return MoonBridge.LI_ERR_UNSUPPORTED; + } + } + + public void sendControllerBatteryEvent(byte controllerNumber, byte batteryState, byte batteryPercentage) { + MoonBridge.sendControllerBatteryEvent(controllerNumber, batteryState, batteryPercentage); + } + + public void sendUtf8Text(final String text) { + if (!isMonkey) { + MoonBridge.sendUtf8Text(text); + } + } + + public static String findExternalAddressForMdns(String stunHostname, int stunPort) { + return MoonBridge.findExternalAddressIP4(stunHostname, stunPort); + } +} diff --git a/app/src/main/java/com/limelight/nvstream/NvConnectionListener.java b/app/src/main/java/com/limelight/nvstream/NvConnectionListener.java old mode 100644 new mode 100755 index a6109dbfe5..577f637d99 --- a/app/src/main/java/com/limelight/nvstream/NvConnectionListener.java +++ b/app/src/main/java/com/limelight/nvstream/NvConnectionListener.java @@ -1,23 +1,23 @@ -package com.limelight.nvstream; - -public interface NvConnectionListener { - void stageStarting(String stage); - void stageComplete(String stage); - void stageFailed(String stage, int portFlags, int errorCode); - - void connectionStarted(); - void connectionTerminated(int errorCode); - void connectionStatusUpdate(int connectionStatus); - - void displayMessage(String message); - void displayTransientMessage(String message); - - void rumble(short controllerNumber, short lowFreqMotor, short highFreqMotor); - void rumbleTriggers(short controllerNumber, short leftTrigger, short rightTrigger); - - void setHdrMode(boolean enabled, byte[] hdrMetadata); - - void setMotionEventState(short controllerNumber, byte motionType, short reportRateHz); - - void setControllerLED(short controllerNumber, byte r, byte g, byte b); -} +package com.limelight.nvstream; + +public interface NvConnectionListener { + void stageStarting(String stage); + void stageComplete(String stage); + void stageFailed(String stage, int portFlags, int errorCode); + + void connectionStarted(); + void connectionTerminated(int errorCode); + void connectionStatusUpdate(int connectionStatus); + + void displayMessage(String message); + void displayTransientMessage(String message); + + void rumble(short controllerNumber, short lowFreqMotor, short highFreqMotor); + void rumbleTriggers(short controllerNumber, short leftTrigger, short rightTrigger); + + void setHdrMode(boolean enabled, byte[] hdrMetadata); + + void setMotionEventState(short controllerNumber, byte motionType, short reportRateHz); + + void setControllerLED(short controllerNumber, byte r, byte g, byte b); +} diff --git a/app/src/main/java/com/limelight/nvstream/StreamConfiguration.java b/app/src/main/java/com/limelight/nvstream/StreamConfiguration.java old mode 100644 new mode 100755 index 317f9381c1..98e83612a0 --- a/app/src/main/java/com/limelight/nvstream/StreamConfiguration.java +++ b/app/src/main/java/com/limelight/nvstream/StreamConfiguration.java @@ -1,224 +1,224 @@ -package com.limelight.nvstream; - -import com.limelight.nvstream.http.NvApp; -import com.limelight.nvstream.jni.MoonBridge; - -public class StreamConfiguration { - public static final int INVALID_APP_ID = 0; - - public static final int STREAM_CFG_LOCAL = 0; - public static final int STREAM_CFG_REMOTE = 1; - public static final int STREAM_CFG_AUTO = 2; - - private NvApp app; - private int width, height; - private int refreshRate; - private int launchRefreshRate; - private int clientRefreshRateX100; - private int bitrate; - private boolean sops; - private boolean enableAdaptiveResolution; - private boolean playLocalAudio; - private int maxPacketSize; - private int remote; - private MoonBridge.AudioConfiguration audioConfiguration; - private int supportedVideoFormats; - private int attachedGamepadMask; - private int encryptionFlags; - private int colorRange; - private int colorSpace; - private boolean persistGamepadsAfterDisconnect; - - public static class Builder { - private StreamConfiguration config = new StreamConfiguration(); - - public StreamConfiguration.Builder setApp(NvApp app) { - config.app = app; - return this; - } - - public StreamConfiguration.Builder setRemoteConfiguration(int remote) { - config.remote = remote; - return this; - } - - public StreamConfiguration.Builder setResolution(int width, int height) { - config.width = width; - config.height = height; - return this; - } - - public StreamConfiguration.Builder setRefreshRate(int refreshRate) { - config.refreshRate = refreshRate; - return this; - } - - public StreamConfiguration.Builder setLaunchRefreshRate(int refreshRate) { - config.launchRefreshRate = refreshRate; - return this; - } - - public StreamConfiguration.Builder setBitrate(int bitrate) { - config.bitrate = bitrate; - return this; - } - - public StreamConfiguration.Builder setEnableSops(boolean enable) { - config.sops = enable; - return this; - } - - public StreamConfiguration.Builder enableAdaptiveResolution(boolean enable) { - config.enableAdaptiveResolution = enable; - return this; - } - - public StreamConfiguration.Builder enableLocalAudioPlayback(boolean enable) { - config.playLocalAudio = enable; - return this; - } - - public StreamConfiguration.Builder setMaxPacketSize(int maxPacketSize) { - config.maxPacketSize = maxPacketSize; - return this; - } - - public StreamConfiguration.Builder setAttachedGamepadMask(int attachedGamepadMask) { - config.attachedGamepadMask = attachedGamepadMask; - return this; - } - - public StreamConfiguration.Builder setAttachedGamepadMaskByCount(int gamepadCount) { - config.attachedGamepadMask = 0; - for (int i = 0; i < 4; i++) { - if (gamepadCount > i) { - config.attachedGamepadMask |= 1 << i; - } - } - return this; - } - - public StreamConfiguration.Builder setPersistGamepadsAfterDisconnect(boolean value) { - config.persistGamepadsAfterDisconnect = value; - return this; - } - - public StreamConfiguration.Builder setClientRefreshRateX100(int refreshRateX100) { - config.clientRefreshRateX100 = refreshRateX100; - return this; - } - - public StreamConfiguration.Builder setAudioConfiguration(MoonBridge.AudioConfiguration audioConfig) { - config.audioConfiguration = audioConfig; - return this; - } - - public StreamConfiguration.Builder setSupportedVideoFormats(int supportedVideoFormats) { - config.supportedVideoFormats = supportedVideoFormats; - return this; - } - - public StreamConfiguration.Builder setColorRange(int colorRange) { - config.colorRange = colorRange; - return this; - } - - public StreamConfiguration.Builder setColorSpace(int colorSpace) { - config.colorSpace = colorSpace; - return this; - } - - public StreamConfiguration build() { - return config; - } - } - - private StreamConfiguration() { - // Set default attributes - this.app = new NvApp("Steam"); - this.width = 1280; - this.height = 720; - this.refreshRate = 60; - this.launchRefreshRate = 60; - this.bitrate = 10000; - this.maxPacketSize = 1024; - this.remote = STREAM_CFG_AUTO; - this.sops = true; - this.enableAdaptiveResolution = false; - this.audioConfiguration = MoonBridge.AUDIO_CONFIGURATION_STEREO; - this.supportedVideoFormats = MoonBridge.VIDEO_FORMAT_H264; - this.attachedGamepadMask = 0; - } - - public int getWidth() { - return width; - } - - public int getHeight() { - return height; - } - - public int getRefreshRate() { - return refreshRate; - } - - public int getLaunchRefreshRate() { - return launchRefreshRate; - } - - public int getBitrate() { - return bitrate; - } - - public int getMaxPacketSize() { - return maxPacketSize; - } - - public NvApp getApp() { - return app; - } - - public boolean getSops() { - return sops; - } - - public boolean getAdaptiveResolutionEnabled() { - return enableAdaptiveResolution; - } - - public boolean getPlayLocalAudio() { - return playLocalAudio; - } - - public int getRemote() { - return remote; - } - - public MoonBridge.AudioConfiguration getAudioConfiguration() { - return audioConfiguration; - } - - public int getSupportedVideoFormats() { - return supportedVideoFormats; - } - - public int getAttachedGamepadMask() { - return attachedGamepadMask; - } - - public boolean getPersistGamepadsAfterDisconnect() { - return persistGamepadsAfterDisconnect; - } - - public int getClientRefreshRateX100() { - return clientRefreshRateX100; - } - - public int getColorRange() { - return colorRange; - } - - public int getColorSpace() { - return colorSpace; - } -} +package com.limelight.nvstream; + +import com.limelight.nvstream.http.NvApp; +import com.limelight.nvstream.jni.MoonBridge; + +public class StreamConfiguration { + public static final int INVALID_APP_ID = 0; + + public static final int STREAM_CFG_LOCAL = 0; + public static final int STREAM_CFG_REMOTE = 1; + public static final int STREAM_CFG_AUTO = 2; + + private NvApp app; + private int width, height; + private int refreshRate; + private int launchRefreshRate; + private int clientRefreshRateX100; + private int bitrate; + private boolean sops; + private boolean enableAdaptiveResolution; + private boolean playLocalAudio; + private int maxPacketSize; + private int remote; + private MoonBridge.AudioConfiguration audioConfiguration; + private int supportedVideoFormats; + private int attachedGamepadMask; + private int encryptionFlags; + private int colorRange; + private int colorSpace; + private boolean persistGamepadsAfterDisconnect; + + public static class Builder { + private StreamConfiguration config = new StreamConfiguration(); + + public StreamConfiguration.Builder setApp(NvApp app) { + config.app = app; + return this; + } + + public StreamConfiguration.Builder setRemoteConfiguration(int remote) { + config.remote = remote; + return this; + } + + public StreamConfiguration.Builder setResolution(int width, int height) { + config.width = width; + config.height = height; + return this; + } + + public StreamConfiguration.Builder setRefreshRate(int refreshRate) { + config.refreshRate = refreshRate; + return this; + } + + public StreamConfiguration.Builder setLaunchRefreshRate(int refreshRate) { + config.launchRefreshRate = refreshRate; + return this; + } + + public StreamConfiguration.Builder setBitrate(int bitrate) { + config.bitrate = bitrate; + return this; + } + + public StreamConfiguration.Builder setEnableSops(boolean enable) { + config.sops = enable; + return this; + } + + public StreamConfiguration.Builder enableAdaptiveResolution(boolean enable) { + config.enableAdaptiveResolution = enable; + return this; + } + + public StreamConfiguration.Builder enableLocalAudioPlayback(boolean enable) { + config.playLocalAudio = enable; + return this; + } + + public StreamConfiguration.Builder setMaxPacketSize(int maxPacketSize) { + config.maxPacketSize = maxPacketSize; + return this; + } + + public StreamConfiguration.Builder setAttachedGamepadMask(int attachedGamepadMask) { + config.attachedGamepadMask = attachedGamepadMask; + return this; + } + + public StreamConfiguration.Builder setAttachedGamepadMaskByCount(int gamepadCount) { + config.attachedGamepadMask = 0; + for (int i = 0; i < 4; i++) { + if (gamepadCount > i) { + config.attachedGamepadMask |= 1 << i; + } + } + return this; + } + + public StreamConfiguration.Builder setPersistGamepadsAfterDisconnect(boolean value) { + config.persistGamepadsAfterDisconnect = value; + return this; + } + + public StreamConfiguration.Builder setClientRefreshRateX100(int refreshRateX100) { + config.clientRefreshRateX100 = refreshRateX100; + return this; + } + + public StreamConfiguration.Builder setAudioConfiguration(MoonBridge.AudioConfiguration audioConfig) { + config.audioConfiguration = audioConfig; + return this; + } + + public StreamConfiguration.Builder setSupportedVideoFormats(int supportedVideoFormats) { + config.supportedVideoFormats = supportedVideoFormats; + return this; + } + + public StreamConfiguration.Builder setColorRange(int colorRange) { + config.colorRange = colorRange; + return this; + } + + public StreamConfiguration.Builder setColorSpace(int colorSpace) { + config.colorSpace = colorSpace; + return this; + } + + public StreamConfiguration build() { + return config; + } + } + + private StreamConfiguration() { + // Set default attributes + this.app = new NvApp("Steam"); + this.width = 1280; + this.height = 720; + this.refreshRate = 60; + this.launchRefreshRate = 60; + this.bitrate = 10000; + this.maxPacketSize = 1024; + this.remote = STREAM_CFG_AUTO; + this.sops = true; + this.enableAdaptiveResolution = false; + this.audioConfiguration = MoonBridge.AUDIO_CONFIGURATION_STEREO; + this.supportedVideoFormats = MoonBridge.VIDEO_FORMAT_H264; + this.attachedGamepadMask = 0; + } + + public int getWidth() { + return width; + } + + public int getHeight() { + return height; + } + + public int getRefreshRate() { + return refreshRate; + } + + public int getLaunchRefreshRate() { + return launchRefreshRate; + } + + public int getBitrate() { + return bitrate; + } + + public int getMaxPacketSize() { + return maxPacketSize; + } + + public NvApp getApp() { + return app; + } + + public boolean getSops() { + return sops; + } + + public boolean getAdaptiveResolutionEnabled() { + return enableAdaptiveResolution; + } + + public boolean getPlayLocalAudio() { + return playLocalAudio; + } + + public int getRemote() { + return remote; + } + + public MoonBridge.AudioConfiguration getAudioConfiguration() { + return audioConfiguration; + } + + public int getSupportedVideoFormats() { + return supportedVideoFormats; + } + + public int getAttachedGamepadMask() { + return attachedGamepadMask; + } + + public boolean getPersistGamepadsAfterDisconnect() { + return persistGamepadsAfterDisconnect; + } + + public int getClientRefreshRateX100() { + return clientRefreshRateX100; + } + + public int getColorRange() { + return colorRange; + } + + public int getColorSpace() { + return colorSpace; + } +} diff --git a/app/src/main/java/com/limelight/nvstream/av/ByteBufferDescriptor.java b/app/src/main/java/com/limelight/nvstream/av/ByteBufferDescriptor.java old mode 100644 new mode 100755 index b2a5910141..35e9419166 --- a/app/src/main/java/com/limelight/nvstream/av/ByteBufferDescriptor.java +++ b/app/src/main/java/com/limelight/nvstream/av/ByteBufferDescriptor.java @@ -1,57 +1,57 @@ -package com.limelight.nvstream.av; - -public class ByteBufferDescriptor { - public byte[] data; - public int offset; - public int length; - - public ByteBufferDescriptor nextDescriptor; - - public ByteBufferDescriptor(byte[] data, int offset, int length) - { - this.data = data; - this.offset = offset; - this.length = length; - } - - public ByteBufferDescriptor(ByteBufferDescriptor desc) - { - this.data = desc.data; - this.offset = desc.offset; - this.length = desc.length; - } - - public void reinitialize(byte[] data, int offset, int length) - { - this.data = data; - this.offset = offset; - this.length = length; - this.nextDescriptor = null; - } - - public void print() - { - print(offset, length); - } - - public void print(int length) - { - print(this.offset, length); - } - - public void print(int offset, int length) - { - for (int i = offset; i < offset+length;) { - if (i + 8 <= offset+length) { - System.out.printf("%x: %02x %02x %02x %02x %02x %02x %02x %02x\n", i, - data[i], data[i+1], data[i+2], data[i+3], data[i+4], data[i+5], data[i+6], data[i+7]); - i += 8; - } - else { - System.out.printf("%x: %02x \n", i, data[i]); - i++; - } - } - System.out.println(); - } -} +package com.limelight.nvstream.av; + +public class ByteBufferDescriptor { + public byte[] data; + public int offset; + public int length; + + public ByteBufferDescriptor nextDescriptor; + + public ByteBufferDescriptor(byte[] data, int offset, int length) + { + this.data = data; + this.offset = offset; + this.length = length; + } + + public ByteBufferDescriptor(ByteBufferDescriptor desc) + { + this.data = desc.data; + this.offset = desc.offset; + this.length = desc.length; + } + + public void reinitialize(byte[] data, int offset, int length) + { + this.data = data; + this.offset = offset; + this.length = length; + this.nextDescriptor = null; + } + + public void print() + { + print(offset, length); + } + + public void print(int length) + { + print(this.offset, length); + } + + public void print(int offset, int length) + { + for (int i = offset; i < offset+length;) { + if (i + 8 <= offset+length) { + System.out.printf("%x: %02x %02x %02x %02x %02x %02x %02x %02x\n", i, + data[i], data[i+1], data[i+2], data[i+3], data[i+4], data[i+5], data[i+6], data[i+7]); + i += 8; + } + else { + System.out.printf("%x: %02x \n", i, data[i]); + i++; + } + } + System.out.println(); + } +} diff --git a/app/src/main/java/com/limelight/nvstream/av/audio/AudioRenderer.java b/app/src/main/java/com/limelight/nvstream/av/audio/AudioRenderer.java old mode 100644 new mode 100755 index 8b0053b2b1..49674bfe59 --- a/app/src/main/java/com/limelight/nvstream/av/audio/AudioRenderer.java +++ b/app/src/main/java/com/limelight/nvstream/av/audio/AudioRenderer.java @@ -1,15 +1,15 @@ -package com.limelight.nvstream.av.audio; - -import com.limelight.nvstream.jni.MoonBridge; - -public interface AudioRenderer { - int setup(MoonBridge.AudioConfiguration audioConfiguration, int sampleRate, int samplesPerFrame); - - void start(); - - void stop(); - - void playDecodedAudio(short[] audioData); - - void cleanup(); -} +package com.limelight.nvstream.av.audio; + +import com.limelight.nvstream.jni.MoonBridge; + +public interface AudioRenderer { + int setup(MoonBridge.AudioConfiguration audioConfiguration, int sampleRate, int samplesPerFrame); + + void start(); + + void stop(); + + void playDecodedAudio(short[] audioData); + + void cleanup(); +} diff --git a/app/src/main/java/com/limelight/nvstream/av/video/VideoDecoderRenderer.java b/app/src/main/java/com/limelight/nvstream/av/video/VideoDecoderRenderer.java old mode 100644 new mode 100755 index 8c222eeee8..673a90aeeb --- a/app/src/main/java/com/limelight/nvstream/av/video/VideoDecoderRenderer.java +++ b/app/src/main/java/com/limelight/nvstream/av/video/VideoDecoderRenderer.java @@ -1,21 +1,21 @@ -package com.limelight.nvstream.av.video; - -public abstract class VideoDecoderRenderer { - public abstract int setup(int format, int width, int height, int redrawRate); - - public abstract void start(); - - public abstract void stop(); - - // This is called once for each frame-start NALU. This means it will be called several times - // for an IDR frame which contains several parameter sets and the I-frame data. - public abstract int submitDecodeUnit(byte[] decodeUnitData, int decodeUnitLength, int decodeUnitType, - int frameNumber, int frameType, char frameHostProcessingLatency, - long receiveTimeMs, long enqueueTimeMs); - - public abstract void cleanup(); - - public abstract int getCapabilities(); - - public abstract void setHdrMode(boolean enabled, byte[] hdrMetadata); -} +package com.limelight.nvstream.av.video; + +public abstract class VideoDecoderRenderer { + public abstract int setup(int format, int width, int height, int redrawRate); + + public abstract void start(); + + public abstract void stop(); + + // This is called once for each frame-start NALU. This means it will be called several times + // for an IDR frame which contains several parameter sets and the I-frame data. + public abstract int submitDecodeUnit(byte[] decodeUnitData, int decodeUnitLength, int decodeUnitType, + int frameNumber, int frameType, char frameHostProcessingLatency, + long receiveTimeMs, long enqueueTimeMs); + + public abstract void cleanup(); + + public abstract int getCapabilities(); + + public abstract void setHdrMode(boolean enabled, byte[] hdrMetadata); +} diff --git a/app/src/main/java/com/limelight/nvstream/http/ComputerDetails.java b/app/src/main/java/com/limelight/nvstream/http/ComputerDetails.java old mode 100644 new mode 100755 index 44ed59628d..aaea70fd86 --- a/app/src/main/java/com/limelight/nvstream/http/ComputerDetails.java +++ b/app/src/main/java/com/limelight/nvstream/http/ComputerDetails.java @@ -1,168 +1,168 @@ -package com.limelight.nvstream.http; - -import java.security.cert.X509Certificate; -import java.util.Objects; - - -public class ComputerDetails { - public enum State { - ONLINE, OFFLINE, UNKNOWN - } - - public static class AddressTuple { - public String address; - public int port; - - public AddressTuple(String address, int port) { - if (address == null) { - throw new IllegalArgumentException("Address cannot be null"); - } - if (port <= 0) { - throw new IllegalArgumentException("Invalid port"); - } - - // If this was an escaped IPv6 address, remove the brackets - if (address.startsWith("[") && address.endsWith("]")) { - address = address.substring(1, address.length() - 1); - } - - this.address = address; - this.port = port; - } - - @Override - public int hashCode() { - return Objects.hash(address, port); - } - - @Override - public boolean equals(Object obj) { - if (!(obj instanceof AddressTuple)) { - return false; - } - - AddressTuple that = (AddressTuple) obj; - return address.equals(that.address) && port == that.port; - } - - public String toString() { - if (address.contains(":")) { - // IPv6 - return "[" + address + "]:" + port; - } - else { - // IPv4 and hostnames - return address + ":" + port; - } - } - } - - // Persistent attributes - public String uuid; - public String name; - public AddressTuple localAddress; - public AddressTuple remoteAddress; - public AddressTuple manualAddress; - public AddressTuple ipv6Address; - public String macAddress; - public X509Certificate serverCert; - - // Transient attributes - public State state; - public AddressTuple activeAddress; - public int httpsPort; - public int externalPort; - public PairingManager.PairState pairState; - public int runningGameId; - public String rawAppList; - public boolean nvidiaServer; - - public ComputerDetails() { - // Use defaults - state = State.UNKNOWN; - } - - public ComputerDetails(ComputerDetails details) { - // Copy details from the other computer - update(details); - } - - public int guessExternalPort() { - if (externalPort != 0) { - return externalPort; - } - else if (remoteAddress != null) { - return remoteAddress.port; - } - else if (activeAddress != null) { - return activeAddress.port; - } - else if (ipv6Address != null) { - return ipv6Address.port; - } - else if (localAddress != null) { - return localAddress.port; - } - else { - return NvHTTP.DEFAULT_HTTP_PORT; - } - } - - public void update(ComputerDetails details) { - this.state = details.state; - this.name = details.name; - this.uuid = details.uuid; - if (details.activeAddress != null) { - this.activeAddress = details.activeAddress; - } - // We can get IPv4 loopback addresses with GS IPv6 Forwarder - if (details.localAddress != null && !details.localAddress.address.startsWith("127.")) { - this.localAddress = details.localAddress; - } - if (details.remoteAddress != null) { - this.remoteAddress = details.remoteAddress; - } - else if (this.remoteAddress != null && details.externalPort != 0) { - // If we have a remote address already (perhaps via STUN) but our updated details - // don't have a new one (because GFE doesn't send one), propagate the external - // port to the current remote address. We may have tried to guess it previously. - this.remoteAddress.port = details.externalPort; - } - if (details.manualAddress != null) { - this.manualAddress = details.manualAddress; - } - if (details.ipv6Address != null) { - this.ipv6Address = details.ipv6Address; - } - if (details.macAddress != null && !details.macAddress.equals("00:00:00:00:00:00")) { - this.macAddress = details.macAddress; - } - if (details.serverCert != null) { - this.serverCert = details.serverCert; - } - this.externalPort = details.externalPort; - this.httpsPort = details.httpsPort; - this.pairState = details.pairState; - this.runningGameId = details.runningGameId; - this.nvidiaServer = details.nvidiaServer; - this.rawAppList = details.rawAppList; - } - - @Override - public String toString() { - StringBuilder str = new StringBuilder(); - str.append("Name: ").append(name).append("\n"); - str.append("State: ").append(state).append("\n"); - str.append("Active Address: ").append(activeAddress).append("\n"); - str.append("UUID: ").append(uuid).append("\n"); - str.append("Local Address: ").append(localAddress).append("\n"); - str.append("Remote Address: ").append(remoteAddress).append("\n"); - str.append("IPv6 Address: ").append(ipv6Address).append("\n"); - str.append("Manual Address: ").append(manualAddress).append("\n"); - str.append("MAC Address: ").append(macAddress).append("\n"); - str.append("Pair State: ").append(pairState).append("\n"); - str.append("Running Game ID: ").append(runningGameId).append("\n"); - str.append("HTTPS Port: ").append(httpsPort).append("\n"); - return str.toString(); - } -} +package com.limelight.nvstream.http; + +import java.security.cert.X509Certificate; +import java.util.Objects; + + +public class ComputerDetails { + public enum State { + ONLINE, OFFLINE, UNKNOWN + } + + public static class AddressTuple { + public String address; + public int port; + + public AddressTuple(String address, int port) { + if (address == null) { + throw new IllegalArgumentException("Address cannot be null"); + } + if (port <= 0) { + throw new IllegalArgumentException("Invalid port"); + } + + // If this was an escaped IPv6 address, remove the brackets + if (address.startsWith("[") && address.endsWith("]")) { + address = address.substring(1, address.length() - 1); + } + + this.address = address; + this.port = port; + } + + @Override + public int hashCode() { + return Objects.hash(address, port); + } + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof AddressTuple)) { + return false; + } + + AddressTuple that = (AddressTuple) obj; + return address.equals(that.address) && port == that.port; + } + + public String toString() { + if (address.contains(":")) { + // IPv6 + return "[" + address + "]:" + port; + } + else { + // IPv4 and hostnames + return address + ":" + port; + } + } + } + + // Persistent attributes + public String uuid; + public String name; + public AddressTuple localAddress; + public AddressTuple remoteAddress; + public AddressTuple manualAddress; + public AddressTuple ipv6Address; + public String macAddress; + public X509Certificate serverCert; + + // Transient attributes + public State state; + public AddressTuple activeAddress; + public int httpsPort; + public int externalPort; + public PairingManager.PairState pairState; + public int runningGameId; + public String rawAppList; + public boolean nvidiaServer; + + public ComputerDetails() { + // Use defaults + state = State.UNKNOWN; + } + + public ComputerDetails(ComputerDetails details) { + // Copy details from the other computer + update(details); + } + + public int guessExternalPort() { + if (externalPort != 0) { + return externalPort; + } + else if (remoteAddress != null) { + return remoteAddress.port; + } + else if (activeAddress != null) { + return activeAddress.port; + } + else if (ipv6Address != null) { + return ipv6Address.port; + } + else if (localAddress != null) { + return localAddress.port; + } + else { + return NvHTTP.DEFAULT_HTTP_PORT; + } + } + + public void update(ComputerDetails details) { + this.state = details.state; + this.name = details.name; + this.uuid = details.uuid; + if (details.activeAddress != null) { + this.activeAddress = details.activeAddress; + } + // We can get IPv4 loopback addresses with GS IPv6 Forwarder + if (details.localAddress != null && !details.localAddress.address.startsWith("127.")) { + this.localAddress = details.localAddress; + } + if (details.remoteAddress != null) { + this.remoteAddress = details.remoteAddress; + } + else if (this.remoteAddress != null && details.externalPort != 0) { + // If we have a remote address already (perhaps via STUN) but our updated details + // don't have a new one (because GFE doesn't send one), propagate the external + // port to the current remote address. We may have tried to guess it previously. + this.remoteAddress.port = details.externalPort; + } + if (details.manualAddress != null) { + this.manualAddress = details.manualAddress; + } + if (details.ipv6Address != null) { + this.ipv6Address = details.ipv6Address; + } + if (details.macAddress != null && !details.macAddress.equals("00:00:00:00:00:00")) { + this.macAddress = details.macAddress; + } + if (details.serverCert != null) { + this.serverCert = details.serverCert; + } + this.externalPort = details.externalPort; + this.httpsPort = details.httpsPort; + this.pairState = details.pairState; + this.runningGameId = details.runningGameId; + this.nvidiaServer = details.nvidiaServer; + this.rawAppList = details.rawAppList; + } + + @Override + public String toString() { + StringBuilder str = new StringBuilder(); + str.append("Name: ").append(name).append("\n"); + str.append("State: ").append(state).append("\n"); + str.append("Active Address: ").append(activeAddress).append("\n"); + str.append("UUID: ").append(uuid).append("\n"); + str.append("Local Address: ").append(localAddress).append("\n"); + str.append("Remote Address: ").append(remoteAddress).append("\n"); + str.append("IPv6 Address: ").append(ipv6Address).append("\n"); + str.append("Manual Address: ").append(manualAddress).append("\n"); + str.append("MAC Address: ").append(macAddress).append("\n"); + str.append("Pair State: ").append(pairState).append("\n"); + str.append("Running Game ID: ").append(runningGameId).append("\n"); + str.append("HTTPS Port: ").append(httpsPort).append("\n"); + return str.toString(); + } +} diff --git a/app/src/main/java/com/limelight/nvstream/http/HostHttpResponseException.java b/app/src/main/java/com/limelight/nvstream/http/HostHttpResponseException.java old mode 100644 new mode 100755 index 25a883347e..196b96b1b2 --- a/app/src/main/java/com/limelight/nvstream/http/HostHttpResponseException.java +++ b/app/src/main/java/com/limelight/nvstream/http/HostHttpResponseException.java @@ -1,28 +1,28 @@ -package com.limelight.nvstream.http; - -import java.io.IOException; - -public class HostHttpResponseException extends IOException { - private static final long serialVersionUID = 1543508830807804222L; - - private int errorCode; - private String errorMsg; - - public HostHttpResponseException(int errorCode, String errorMsg) { - this.errorCode = errorCode; - this.errorMsg = errorMsg; - } - - public int getErrorCode() { - return errorCode; - } - - public String getErrorMessage() { - return errorMsg; - } - - @Override - public String getMessage() { - return "Host PC returned error: "+errorMsg+" (Error code: "+errorCode+")"; - } -} +package com.limelight.nvstream.http; + +import java.io.IOException; + +public class HostHttpResponseException extends IOException { + private static final long serialVersionUID = 1543508830807804222L; + + private int errorCode; + private String errorMsg; + + public HostHttpResponseException(int errorCode, String errorMsg) { + this.errorCode = errorCode; + this.errorMsg = errorMsg; + } + + public int getErrorCode() { + return errorCode; + } + + public String getErrorMessage() { + return errorMsg; + } + + @Override + public String getMessage() { + return "Host PC returned error: "+errorMsg+" (Error code: "+errorCode+")"; + } +} diff --git a/app/src/main/java/com/limelight/nvstream/http/LimelightCryptoProvider.java b/app/src/main/java/com/limelight/nvstream/http/LimelightCryptoProvider.java old mode 100644 new mode 100755 index 1232f6673b..a75b83711b --- a/app/src/main/java/com/limelight/nvstream/http/LimelightCryptoProvider.java +++ b/app/src/main/java/com/limelight/nvstream/http/LimelightCryptoProvider.java @@ -1,11 +1,11 @@ -package com.limelight.nvstream.http; - -import java.security.PrivateKey; -import java.security.cert.X509Certificate; - -public interface LimelightCryptoProvider { - X509Certificate getClientCertificate(); - PrivateKey getClientPrivateKey(); - byte[] getPemEncodedClientCertificate(); - String encodeBase64String(byte[] data); -} +package com.limelight.nvstream.http; + +import java.security.PrivateKey; +import java.security.cert.X509Certificate; + +public interface LimelightCryptoProvider { + X509Certificate getClientCertificate(); + PrivateKey getClientPrivateKey(); + byte[] getPemEncodedClientCertificate(); + String encodeBase64String(byte[] data); +} diff --git a/app/src/main/java/com/limelight/nvstream/http/NvApp.java b/app/src/main/java/com/limelight/nvstream/http/NvApp.java old mode 100644 new mode 100755 index bb5a1072c7..fd3a307fe0 --- a/app/src/main/java/com/limelight/nvstream/http/NvApp.java +++ b/app/src/main/java/com/limelight/nvstream/http/NvApp.java @@ -1,70 +1,70 @@ -package com.limelight.nvstream.http; - -import com.limelight.LimeLog; - -public class NvApp { - private String appName = ""; - private int appId; - private boolean initialized; - private boolean hdrSupported; - - public NvApp() {} - - public NvApp(String appName) { - this.appName = appName; - } - - public NvApp(String appName, int appId, boolean hdrSupported) { - this.appName = appName; - this.appId = appId; - this.hdrSupported = hdrSupported; - this.initialized = true; - } - - public void setAppName(String appName) { - this.appName = appName; - } - - public void setAppId(String appId) { - try { - this.appId = Integer.parseInt(appId); - this.initialized = true; - } catch (NumberFormatException e) { - LimeLog.warning("Malformed app ID: "+appId); - } - } - - public void setAppId(int appId) { - this.appId = appId; - this.initialized = true; - } - - public void setHdrSupported(boolean hdrSupported) { - this.hdrSupported = hdrSupported; - } - - public String getAppName() { - return this.appName; - } - - public int getAppId() { - return this.appId; - } - - public boolean isHdrSupported() { - return this.hdrSupported; - } - - public boolean isInitialized() { - return this.initialized; - } - - @Override - public String toString() { - StringBuilder str = new StringBuilder(); - str.append("Name: ").append(appName).append("\n"); - str.append("HDR Supported: ").append(hdrSupported ? "Yes" : "Unknown").append("\n"); - str.append("ID: ").append(appId).append("\n"); - return str.toString(); - } -} +package com.limelight.nvstream.http; + +import com.limelight.LimeLog; + +public class NvApp { + private String appName = ""; + private int appId; + private boolean initialized; + private boolean hdrSupported; + + public NvApp() {} + + public NvApp(String appName) { + this.appName = appName; + } + + public NvApp(String appName, int appId, boolean hdrSupported) { + this.appName = appName; + this.appId = appId; + this.hdrSupported = hdrSupported; + this.initialized = true; + } + + public void setAppName(String appName) { + this.appName = appName; + } + + public void setAppId(String appId) { + try { + this.appId = Integer.parseInt(appId); + this.initialized = true; + } catch (NumberFormatException e) { + LimeLog.warning("Malformed app ID: "+appId); + } + } + + public void setAppId(int appId) { + this.appId = appId; + this.initialized = true; + } + + public void setHdrSupported(boolean hdrSupported) { + this.hdrSupported = hdrSupported; + } + + public String getAppName() { + return this.appName; + } + + public int getAppId() { + return this.appId; + } + + public boolean isHdrSupported() { + return this.hdrSupported; + } + + public boolean isInitialized() { + return this.initialized; + } + + @Override + public String toString() { + StringBuilder str = new StringBuilder(); + str.append("Name: ").append(appName).append("\n"); + str.append("HDR Supported: ").append(hdrSupported ? "Yes" : "Unknown").append("\n"); + str.append("ID: ").append(appId).append("\n"); + return str.toString(); + } +} diff --git a/app/src/main/java/com/limelight/nvstream/http/NvHTTP.java b/app/src/main/java/com/limelight/nvstream/http/NvHTTP.java old mode 100644 new mode 100755 index c4dcb5374e..82cea11046 --- a/app/src/main/java/com/limelight/nvstream/http/NvHTTP.java +++ b/app/src/main/java/com/limelight/nvstream/http/NvHTTP.java @@ -1,808 +1,808 @@ -package com.limelight.nvstream.http; - -import android.os.Build; - -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.Reader; -import java.io.StringReader; -import java.net.Inet4Address; -import java.net.InetAddress; -import java.net.Proxy; -import java.net.Socket; -import java.security.KeyManagementException; -import java.security.KeyStore; -import java.security.KeyStoreException; -import java.security.NoSuchAlgorithmException; -import java.security.Principal; -import java.security.PrivateKey; -import java.security.SecureRandom; -import java.security.cert.Certificate; -import java.security.cert.CertificateException; -import java.security.cert.X509Certificate; -import java.util.LinkedList; -import java.util.ListIterator; -import java.util.Stack; -import java.util.UUID; -import java.util.concurrent.TimeUnit; - -import javax.net.ssl.HostnameVerifier; -import javax.net.ssl.HttpsURLConnection; -import javax.net.ssl.KeyManager; -import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLHandshakeException; -import javax.net.ssl.SSLPeerUnverifiedException; -import javax.net.ssl.SSLSession; -import javax.net.ssl.SSLSocket; -import javax.net.ssl.SSLSocketFactory; -import javax.net.ssl.TrustManager; -import javax.net.ssl.TrustManagerFactory; -import javax.net.ssl.X509KeyManager; -import javax.net.ssl.X509TrustManager; - -import org.xmlpull.v1.XmlPullParser; -import org.xmlpull.v1.XmlPullParserException; -import org.xmlpull.v1.XmlPullParserFactory; - -import com.limelight.BuildConfig; -import com.limelight.LimeLog; -import com.limelight.nvstream.ConnectionContext; -import com.limelight.nvstream.http.PairingManager.PairState; -import com.limelight.nvstream.jni.MoonBridge; - -import okhttp3.ConnectionPool; -import okhttp3.HttpUrl; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; -import okhttp3.ResponseBody; - - -public class NvHTTP { - private String uniqueId; - private PairingManager pm; - - private static final int DEFAULT_HTTPS_PORT = 47984; - public static final int DEFAULT_HTTP_PORT = 47989; - public static final int SHORT_CONNECTION_TIMEOUT = 3000; - public static final int LONG_CONNECTION_TIMEOUT = 5000; - public static final int READ_TIMEOUT = 7000; - - // Print URL and content to logcat on debug builds - private static boolean verbose = BuildConfig.DEBUG; - - private HttpUrl baseUrlHttp; - - private int httpsPort; - - private OkHttpClient httpClientLongConnectTimeout; - private OkHttpClient httpClientLongConnectNoReadTimeout; - private OkHttpClient httpClientShortConnectTimeout; - - private X509TrustManager defaultTrustManager; - private X509TrustManager trustManager; - private X509KeyManager keyManager; - private X509Certificate serverCert; - - void setServerCert(X509Certificate serverCert) { - this.serverCert = serverCert; - } - - private static X509TrustManager getDefaultTrustManager() { - try { - TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); - tmf.init((KeyStore) null); - - for (TrustManager tm : tmf.getTrustManagers()) { - if (tm instanceof X509TrustManager) { - return (X509TrustManager) tm; - } - } - } catch (NoSuchAlgorithmException e) { - throw new RuntimeException(e); - } catch (KeyStoreException e) { - throw new RuntimeException(e); - } - - throw new IllegalStateException("No X509 trust manager found"); - } - - private void initializeHttpState(final LimelightCryptoProvider cryptoProvider) { - keyManager = new X509KeyManager() { - public String chooseClientAlias(String[] keyTypes, - Principal[] issuers, Socket socket) { return "Limelight-RSA"; } - public String chooseServerAlias(String keyType, Principal[] issuers, - Socket socket) { return null; } - public X509Certificate[] getCertificateChain(String alias) { - return new X509Certificate[] {cryptoProvider.getClientCertificate()}; - } - public String[] getClientAliases(String keyType, Principal[] issuers) { return null; } - public PrivateKey getPrivateKey(String alias) { - return cryptoProvider.getClientPrivateKey(); - } - public String[] getServerAliases(String keyType, Principal[] issuers) { return null; } - }; - - defaultTrustManager = getDefaultTrustManager(); - trustManager = new X509TrustManager() { - public X509Certificate[] getAcceptedIssuers() { - return new X509Certificate[0]; - } - public void checkClientTrusted(X509Certificate[] certs, String authType) { - throw new IllegalStateException("Should never be called"); - } - public void checkServerTrusted(X509Certificate[] certs, String authType) throws CertificateException { - try { - // Try the default trust manager first to allow pairing with certificates - // that chain up to a trusted root CA. This will raise CertificateException - // if the certificate is not trusted (expected for GFE's self-signed certs). - defaultTrustManager.checkServerTrusted(certs, authType); - } catch (CertificateException e) { - // Check the server certificate if we've paired to this host - if (certs.length == 1 && NvHTTP.this.serverCert != null) { - if (!certs[0].equals(NvHTTP.this.serverCert)) { - throw new CertificateException("Certificate mismatch"); - } - } - else { - // The cert chain doesn't look like a self-signed cert or we don't have - // a certificate pinned, so re-throw the original validation error. - throw e; - } - } - } - }; - - HostnameVerifier hv = new HostnameVerifier() { - public boolean verify(String hostname, SSLSession session) { - try { - Certificate[] certificates = session.getPeerCertificates(); - if (certificates.length == 1 && certificates[0].equals(NvHTTP.this.serverCert)) { - // Allow any hostname if it's our pinned cert - return true; - } - } catch (SSLPeerUnverifiedException e) { - e.printStackTrace(); - } - - // Fall back to default HostnameVerifier for validating CA-issued certs - return HttpsURLConnection.getDefaultHostnameVerifier().verify(hostname, session); - } - }; - - httpClientLongConnectTimeout = new OkHttpClient.Builder() - .connectionPool(new ConnectionPool(0, 1, TimeUnit.MILLISECONDS)) - .hostnameVerifier(hv) - .readTimeout(READ_TIMEOUT, TimeUnit.MILLISECONDS) - .connectTimeout(LONG_CONNECTION_TIMEOUT, TimeUnit.MILLISECONDS) - .proxy(Proxy.NO_PROXY) - .build(); - - httpClientShortConnectTimeout = httpClientLongConnectTimeout.newBuilder() - .connectTimeout(SHORT_CONNECTION_TIMEOUT, TimeUnit.MILLISECONDS) - .build(); - - httpClientLongConnectNoReadTimeout = httpClientLongConnectTimeout.newBuilder() - .readTimeout(0, TimeUnit.MILLISECONDS) - .build(); - } - - public HttpUrl getHttpsUrl(boolean likelyOnline) throws IOException { - if (httpsPort == 0) { - // Fetch the HTTPS port if we don't have it already - httpsPort = getHttpsPort(openHttpConnectionToString(likelyOnline ? httpClientLongConnectTimeout : httpClientShortConnectTimeout, - baseUrlHttp, "serverinfo")); - } - - return new HttpUrl.Builder().scheme("https").host(baseUrlHttp.host()).port(httpsPort).build(); - } - - public NvHTTP(ComputerDetails.AddressTuple address, int httpsPort, String uniqueId, X509Certificate serverCert, LimelightCryptoProvider cryptoProvider) throws IOException { - // Use the same UID for all Moonlight clients so we can quit games - // started by other Moonlight clients. - this.uniqueId = "0123456789ABCDEF"; - - this.serverCert = serverCert; - - initializeHttpState(cryptoProvider); - - this.httpsPort = httpsPort; - - try { - // If this is an IPv4-mapped IPv6 address, OkHTTP will choke on it if it's - // in IPv6 form, because InetAddress.getByName() will return an Inet4Address - // for what OkHTTP thinks is an IPv6 address. Normalize it into IPv4 form - // to avoid triggering this bug. - String addressString = address.address; - if (addressString.contains(":") && addressString.contains(".")) { - InetAddress addr = InetAddress.getByName(addressString); - if (addr instanceof Inet4Address) { - addressString = ((Inet4Address)addr).getHostAddress(); - } - } - - this.baseUrlHttp = new HttpUrl.Builder() - .scheme("http") - .host(addressString) - .port(address.port) - .build(); - } catch (IllegalArgumentException e) { - // Encapsulate IllegalArgumentException into IOException for callers to handle more easily - throw new IOException(e); - } - - this.pm = new PairingManager(this, cryptoProvider); - } - - static String getXmlString(Reader r, String tagname, boolean throwIfMissing) throws XmlPullParserException, IOException { - XmlPullParserFactory factory = XmlPullParserFactory.newInstance(); - factory.setNamespaceAware(true); - XmlPullParser xpp = factory.newPullParser(); - - xpp.setInput(r); - int eventType = xpp.getEventType(); - Stack currentTag = new Stack(); - - while (eventType != XmlPullParser.END_DOCUMENT) { - switch (eventType) { - case (XmlPullParser.START_TAG): - if (xpp.getName().equals("root")) { - verifyResponseStatus(xpp); - } - currentTag.push(xpp.getName()); - break; - case (XmlPullParser.END_TAG): - currentTag.pop(); - break; - case (XmlPullParser.TEXT): - if (currentTag.peek().equals(tagname)) { - return xpp.getText(); - } - break; - } - eventType = xpp.next(); - } - - if (throwIfMissing) { - // We throw an XmlPullParserException here for ease of handling in all the various callers. - // We could also throw an IOException, but some callers expect those in cases where the - // host may not be reachable. We want to distinguish unreachable hosts vs. hosts that - // are returning garbage XML to us, so we use XmlPullParserException instead. - throw new XmlPullParserException("Missing mandatory field in host response: "+tagname); - } - - return null; - } - - static String getXmlString(String str, String tagname, boolean throwIfMissing) throws XmlPullParserException, IOException { - return getXmlString(new StringReader(str), tagname, throwIfMissing); - } - - private static void verifyResponseStatus(XmlPullParser xpp) throws HostHttpResponseException { - // We use Long.parseLong() because in rare cases GFE can send back a status code of - // 0xFFFFFFFF, which will cause Integer.parseInt() to throw a NumberFormatException due - // to exceeding Integer.MAX_VALUE. We'll get the desired error code of -1 by just casting - // the resulting long into an int. - int statusCode = (int)Long.parseLong(xpp.getAttributeValue(XmlPullParser.NO_NAMESPACE, "status_code")); - if (statusCode != 200) { - String statusMsg = xpp.getAttributeValue(XmlPullParser.NO_NAMESPACE, "status_message"); - if (statusCode == -1 && "Invalid".equals(statusMsg)) { - // Special case handling an audio capture error which GFE doesn't - // provide any useful status message for. - statusCode = 418; - statusMsg = "Missing audio capture device. Reinstall GeForce Experience."; - } - throw new HostHttpResponseException(statusCode, statusMsg); - } - } - - public String getServerInfo(boolean likelyOnline) throws IOException, XmlPullParserException { - String resp; - - // If we believe the PC is online, give it a little extra time to respond - OkHttpClient client = likelyOnline ? httpClientLongConnectTimeout : httpClientShortConnectTimeout; - - // - // TODO: Shield Hub uses HTTP for this and is able to get an accurate PairStatus with HTTP. - // For some reason, we always see PairStatus is 0 over HTTP and only 1 over HTTPS. It looks - // like there are extra request headers required to make this stuff work over HTTP. - // - - // When we have a pinned cert, use HTTPS to fetch serverinfo and fall back on cert mismatch - if (serverCert != null) { - try { - try { - resp = openHttpConnectionToString(client, getHttpsUrl(likelyOnline), "serverinfo"); - } catch (SSLHandshakeException e) { - // Detect if we failed due to a server cert mismatch - if (e.getCause() instanceof CertificateException) { - // Jump to the GfeHttpResponseException exception handler to retry - // over HTTP which will allow us to pair again to update the cert - throw new HostHttpResponseException(401, "Server certificate mismatch"); - } - else { - throw e; - } - } - - // This will throw an exception if the request came back with a failure status. - // We want this because it will throw us into the HTTP case if the client is unpaired. - getServerVersion(resp); - } - catch (HostHttpResponseException e) { - if (e.getErrorCode() == 401) { - // Cert validation error - fall back to HTTP - return openHttpConnectionToString(client, baseUrlHttp, "serverinfo"); - } - - // If it's not a cert validation error, throw it - throw e; - } - - return resp; - } - else { - // No pinned cert, so use HTTP - return openHttpConnectionToString(client, baseUrlHttp, "serverinfo"); - } - } - - private static ComputerDetails.AddressTuple makeTuple(String address, int port) { - if (address == null) { - return null; - } - - return new ComputerDetails.AddressTuple(address, port); - } - - public ComputerDetails getComputerDetails(String serverInfo) throws IOException, XmlPullParserException { - ComputerDetails details = new ComputerDetails(); - - details.name = getXmlString(serverInfo, "hostname", false); - if (details.name == null || details.name.isEmpty()) { - details.name = "UNKNOWN"; - } - - // UUID is mandatory to determine which machine is responding - details.uuid = getXmlString(serverInfo, "uniqueid", true); - - details.httpsPort = getHttpsPort(serverInfo); - - details.macAddress = getXmlString(serverInfo, "mac", false); - - // FIXME: Do we want to use the current port? - details.localAddress = makeTuple(getXmlString(serverInfo, "LocalIP", false), baseUrlHttp.port()); - - // This is missing on on recent GFE versions, but it's present on Sunshine - details.externalPort = getExternalPort(serverInfo); - details.remoteAddress = makeTuple(getXmlString(serverInfo, "ExternalIP", false), details.externalPort); - - details.pairState = getPairState(serverInfo); - details.runningGameId = getCurrentGame(serverInfo); - - // The MJOLNIR codename was used by GFE but never by any third-party server - details.nvidiaServer = getXmlString(serverInfo, "state", true).contains("MJOLNIR"); - - // We could reach it so it's online - details.state = ComputerDetails.State.ONLINE; - - return details; - } - - public ComputerDetails getComputerDetails(boolean likelyOnline) throws IOException, XmlPullParserException { - return getComputerDetails(getServerInfo(likelyOnline)); - } - - // This hack is Android-specific but we do it on all platforms - // because it doesn't really matter - private OkHttpClient performAndroidTlsHack(OkHttpClient client) { - // Doing this each time we create a socket is required - // to avoid the SSLv3 fallback that causes connection failures - try { - SSLContext sc = SSLContext.getInstance("TLS"); - sc.init(new KeyManager[] { keyManager }, new TrustManager[] { trustManager }, new SecureRandom()); - return client.newBuilder().sslSocketFactory(sc.getSocketFactory(), trustManager).build(); - } catch (NoSuchAlgorithmException | KeyManagementException e) { - throw new RuntimeException(e); - } - } - - private HttpUrl getCompleteUrl(HttpUrl baseUrl, String path, String query) { - return baseUrl.newBuilder() - .addPathSegment(path) - .query(query) - .addQueryParameter("uniqueid", uniqueId) - .addQueryParameter("uuid", UUID.randomUUID().toString()) - .build(); - } - - private ResponseBody openHttpConnection(OkHttpClient client, HttpUrl baseUrl, String path) throws IOException { - return openHttpConnection(client, baseUrl, path, null); - } - - // Read timeout should be enabled for any HTTP query that requires no outside action - // on the GFE server. Examples of queries that DO require outside action are launch, resume, and quit. - // The initial pair query does require outside action (user entering a PIN) but subsequent pairing - // queries do not. - private ResponseBody openHttpConnection(OkHttpClient client, HttpUrl baseUrl, String path, String query) throws IOException { - HttpUrl completeUrl = getCompleteUrl(baseUrl, path, query); - Request request = new Request.Builder().url(completeUrl).get().build(); - Response response = performAndroidTlsHack(client).newCall(request).execute(); - - ResponseBody body = response.body(); - - if (response.isSuccessful()) { - return body; - } - - // Unsuccessful, so close the response body - if (body != null) { - body.close(); - } - - if (response.code() == 404) { - throw new FileNotFoundException(completeUrl.toString()); - } - else { - throw new HostHttpResponseException(response.code(), response.message()); - } - } - - private String openHttpConnectionToString(OkHttpClient client, HttpUrl baseUrl, String path) throws IOException { - return openHttpConnectionToString(client, baseUrl, path, null); - } - - private String openHttpConnectionToString(OkHttpClient client, HttpUrl baseUrl, String path, String query) throws IOException { - try { - ResponseBody resp = openHttpConnection(client, baseUrl, path, query); - String respString = resp.string(); - resp.close(); - - if (verbose && !path.equals("serverinfo")) { - LimeLog.info(getCompleteUrl(baseUrl, path, query)+" -> "+respString); - } - - return respString; - } catch (IOException e) { - if (verbose && !path.equals("serverinfo")) { - LimeLog.warning(getCompleteUrl(baseUrl, path, query)+" -> "+e.getMessage()); - e.printStackTrace(); - } - - throw e; - } - } - - public String getServerVersion(String serverInfo) throws XmlPullParserException, IOException { - // appversion is present in all supported GFE versions - return getXmlString(serverInfo, "appversion", true); - } - - public PairingManager.PairState getPairState() throws IOException, XmlPullParserException { - return getPairState(getServerInfo(true)); - } - - public PairingManager.PairState getPairState(String serverInfo) throws IOException, XmlPullParserException { - // appversion is present in all supported GFE versions - return NvHTTP.getXmlString(serverInfo, "PairStatus", true).equals("1") ? - PairState.PAIRED : PairState.NOT_PAIRED; - } - - public long getMaxLumaPixelsH264(String serverInfo) throws XmlPullParserException, IOException { - // MaxLumaPixelsH264 wasn't present on old GFE versions - String str = getXmlString(serverInfo, "MaxLumaPixelsH264", false); - if (str != null) { - return Long.parseLong(str); - } else { - return 0; - } - } - - public long getMaxLumaPixelsHEVC(String serverInfo) throws XmlPullParserException, IOException { - // MaxLumaPixelsHEVC wasn't present on old GFE versions - String str = getXmlString(serverInfo, "MaxLumaPixelsHEVC", false); - if (str != null) { - return Long.parseLong(str); - } else { - return 0; - } - } - - // Possible meaning of bits - // Bit 0: H.264 Baseline - // Bit 1: H.264 High - // ---- - // Bit 8: HEVC Main - // Bit 9: HEVC Main10 - // Bit 10: HEVC Main10 4:4:4 - // Bit 11: ??? - public long getServerCodecModeSupport(String serverInfo) throws XmlPullParserException, IOException { - // ServerCodecModeSupport wasn't present on old GFE versions - String str = getXmlString(serverInfo, "ServerCodecModeSupport", false); - if (str != null) { - return Long.parseLong(str); - } else { - return 0; - } - } - - public String getGpuType(String serverInfo) throws XmlPullParserException, IOException { - // ServerCodecModeSupport wasn't present on old GFE versions - return getXmlString(serverInfo, "gputype", false); - } - - public String getGfeVersion(String serverInfo) throws XmlPullParserException, IOException { - // ServerCodecModeSupport wasn't present on old GFE versions - return getXmlString(serverInfo, "GfeVersion", false); - } - - public boolean supports4K(String serverInfo) throws XmlPullParserException, IOException { - // Only allow 4K on GFE 3.x. GfeVersion wasn't present on very old versions of GFE. - String gfeVersionStr = getXmlString(serverInfo, "GfeVersion", false); - if (gfeVersionStr == null || gfeVersionStr.startsWith("2.")) { - return false; - } - - return true; - } - - public int getCurrentGame(String serverInfo) throws IOException, XmlPullParserException { - // GFE 2.8 started keeping currentgame set to the last game played. As a result, it no longer - // has the semantics that its name would indicate. To contain the effects of this change as much - // as possible, we'll force the current game to zero if the server isn't in a streaming session. - if (getXmlString(serverInfo, "state", true).endsWith("_SERVER_BUSY")) { - return Integer.parseInt(getXmlString(serverInfo, "currentgame", true)); - } - else { - return 0; - } - } - - public int getHttpsPort(String serverInfo) { - try { - return Integer.parseInt(getXmlString(serverInfo, "HttpsPort", true)); - } catch (XmlPullParserException e) { - e.printStackTrace(); - return DEFAULT_HTTPS_PORT; - } catch (IOException e) { - e.printStackTrace(); - return DEFAULT_HTTPS_PORT; - } - } - - public int getExternalPort(String serverInfo) { - // This is an extension which is not present in GFE. It is present for Sunshine to be able - // to support dynamic HTTP WAN ports without requiring the user to manually enter the port. - try { - return Integer.parseInt(getXmlString(serverInfo, "ExternalPort", true)); - } catch (XmlPullParserException e) { - // Expected on non-Sunshine servers - return baseUrlHttp.port(); - } catch (IOException e) { - e.printStackTrace(); - return baseUrlHttp.port(); - } - } - - public NvApp getAppById(int appId) throws IOException, XmlPullParserException { - LinkedList appList = getAppList(); - for (NvApp appFromList : appList) { - if (appFromList.getAppId() == appId) { - return appFromList; - } - } - return null; - } - - /* NOTE: Only use this function if you know what you're doing. - * It's totally valid to have two apps named the same thing, - * or even nothing at all! Look apps up by ID if at all possible - * using the above function */ - public NvApp getAppByName(String appName) throws IOException, XmlPullParserException { - LinkedList appList = getAppList(); - for (NvApp appFromList : appList) { - if (appFromList.getAppName().equalsIgnoreCase(appName)) { - return appFromList; - } - } - return null; - } - - public PairingManager getPairingManager() { - return pm; - } - - public static LinkedList getAppListByReader(Reader r) throws XmlPullParserException, IOException { - XmlPullParserFactory factory = XmlPullParserFactory.newInstance(); - factory.setNamespaceAware(true); - XmlPullParser xpp = factory.newPullParser(); - - xpp.setInput(r); - int eventType = xpp.getEventType(); - LinkedList appList = new LinkedList(); - Stack currentTag = new Stack(); - boolean rootTerminated = false; - - while (eventType != XmlPullParser.END_DOCUMENT) { - switch (eventType) { - case (XmlPullParser.START_TAG): - if (xpp.getName().equals("root")) { - verifyResponseStatus(xpp); - } - currentTag.push(xpp.getName()); - if (xpp.getName().equals("App")) { - appList.addLast(new NvApp()); - } - break; - case (XmlPullParser.END_TAG): - currentTag.pop(); - if (xpp.getName().equals("root")) { - rootTerminated = true; - } - break; - case (XmlPullParser.TEXT): - NvApp app = appList.getLast(); - if (currentTag.peek().equals("AppTitle")) { - app.setAppName(xpp.getText()); - } else if (currentTag.peek().equals("ID")) { - app.setAppId(xpp.getText()); - } else if (currentTag.peek().equals("IsHdrSupported")) { - app.setHdrSupported(xpp.getText().equals("1")); - } - break; - } - eventType = xpp.next(); - } - - // Throw a malformed XML exception if we've not seen the root tag ended - if (!rootTerminated) { - throw new XmlPullParserException("Malformed XML: Root tag was not terminated"); - } - - // Ensure that all apps in the list are initialized - ListIterator i = appList.listIterator(); - while (i.hasNext()) { - NvApp app = i.next(); - - // Remove uninitialized apps - if (!app.isInitialized()) { - LimeLog.warning("GFE returned incomplete app: "+app.getAppId()+" "+app.getAppName()); - i.remove(); - } - } - - return appList; - } - - public String getAppListRaw() throws IOException { - return openHttpConnectionToString(httpClientLongConnectTimeout, getHttpsUrl(true), "applist"); - } - - public LinkedList getAppList() throws HostHttpResponseException, IOException, XmlPullParserException { - if (verbose) { - // Use the raw function so the app list is printed - return getAppListByReader(new StringReader(getAppListRaw())); - } - else { - try (final ResponseBody resp = openHttpConnection(httpClientLongConnectTimeout, getHttpsUrl(true), "applist")) { - return getAppListByReader(new InputStreamReader(resp.byteStream())); - } - } - } - - String executePairingCommand(String additionalArguments, boolean enableReadTimeout) throws HostHttpResponseException, IOException { - return openHttpConnectionToString(enableReadTimeout ? httpClientLongConnectTimeout : httpClientLongConnectNoReadTimeout, - baseUrlHttp, "pair", "devicename=roth&updateState=1&" + additionalArguments); - } - - String executePairingChallenge() throws HostHttpResponseException, IOException { - return openHttpConnectionToString(httpClientLongConnectTimeout, getHttpsUrl(true), - "pair", "devicename=roth&updateState=1&phrase=pairchallenge"); - } - - public void unpair() throws IOException { - openHttpConnectionToString(httpClientLongConnectTimeout, baseUrlHttp, "unpair"); - } - - public InputStream getBoxArt(NvApp app) throws IOException { - ResponseBody resp = openHttpConnection(httpClientLongConnectTimeout, getHttpsUrl(true), "appasset", "appid=" + app.getAppId() + "&AssetType=2&AssetIdx=0"); - return resp.byteStream(); - } - - public int getServerMajorVersion(String serverInfo) throws XmlPullParserException, IOException { - return getServerAppVersionQuad(serverInfo)[0]; - } - - public int[] getServerAppVersionQuad(String serverInfo) throws XmlPullParserException, IOException { - String serverVersion = getServerVersion(serverInfo); - if (serverVersion == null) { - throw new IllegalArgumentException("Missing server version field"); - } - String[] serverVersionSplit = serverVersion.split("\\."); - if (serverVersionSplit.length != 4) { - throw new IllegalArgumentException("Malformed server version field: "+serverVersion); - } - int[] ret = new int[serverVersionSplit.length]; - for (int i = 0; i < ret.length; i++) { - ret[i] = Integer.parseInt(serverVersionSplit[i]); - } - return ret; - } - - final private static char[] hexArray = "0123456789ABCDEF".toCharArray(); - private static String bytesToHex(byte[] bytes) { - char[] hexChars = new char[bytes.length * 2]; - for ( int j = 0; j < bytes.length; j++ ) { - int v = bytes[j] & 0xFF; - hexChars[j * 2] = hexArray[v >>> 4]; - hexChars[j * 2 + 1] = hexArray[v & 0x0F]; - } - return new String(hexChars); - } - - public boolean launchApp(ConnectionContext context, String verb, int appId, boolean enableHdr) throws IOException, XmlPullParserException { - // Using an FPS value over 60 causes SOPS to default to 720p60, - // so force it to 0 to ensure the correct resolution is set. We - // used to use 60 here but that locked the frame rate to 60 FPS - // on GFE 3.20.3. - int fps = context.isNvidiaServerSoftware && context.streamConfig.getLaunchRefreshRate() > 60 ? - 0 : context.streamConfig.getLaunchRefreshRate(); - - boolean enableSops = context.streamConfig.getSops(); - if (context.isNvidiaServerSoftware) { - // Using an unsupported resolution (not 720p, 1080p, or 4K) causes - // GFE to force SOPS to 720p60. This is fine for < 720p resolutions like - // 360p or 480p, but it is not ideal for 1440p and other resolutions. - // When we detect an unsupported resolution, disable SOPS unless it's under 720p. - // FIXME: Detect support resolutions using the serverinfo response, not a hardcoded list - if (context.negotiatedWidth * context.negotiatedHeight > 1280 * 720 && - context.negotiatedWidth * context.negotiatedHeight != 1920 * 1080 && - context.negotiatedWidth * context.negotiatedHeight != 3840 * 2160) { - LimeLog.info("Disabling SOPS due to non-standard resolution: "+context.negotiatedWidth+"x"+context.negotiatedHeight); - enableSops = false; - } - } - - String xmlStr = openHttpConnectionToString(httpClientLongConnectNoReadTimeout, getHttpsUrl(true), verb, - "appid=" + appId + - "&mode=" + context.negotiatedWidth + "x" + context.negotiatedHeight + "x" + fps + - "&additionalStates=1&sops=" + (enableSops ? 1 : 0) + - "&rikey="+bytesToHex(context.riKey.getEncoded()) + - "&rikeyid="+context.riKeyId + - (!enableHdr ? "" : "&hdrMode=1&clientHdrCapVersion=0&clientHdrCapSupportedFlagsInUint32=0&clientHdrCapMetaDataId=NV_STATIC_METADATA_TYPE_1&clientHdrCapDisplayData=0x0x0x0x0x0x0x0x0x0x0") + - "&localAudioPlayMode=" + (context.streamConfig.getPlayLocalAudio() ? 1 : 0) + - "&surroundAudioInfo=" + context.streamConfig.getAudioConfiguration().getSurroundAudioInfo() + - "&remoteControllersBitmap=" + context.streamConfig.getAttachedGamepadMask() + - "&gcmap=" + context.streamConfig.getAttachedGamepadMask() + - "&gcpersist="+(context.streamConfig.getPersistGamepadsAfterDisconnect() ? 1 : 0) + - MoonBridge.getLaunchUrlQueryParameters()); - if ((verb.equals("launch") && !getXmlString(xmlStr, "gamesession", true).equals("0") || - (verb.equals("resume") && !getXmlString(xmlStr, "resume", true).equals("0")))) { - // sessionUrl0 will be missing for older GFE versions - context.rtspSessionUrl = getXmlString(xmlStr, "sessionUrl0", false); - return true; - } - else { - return false; - } - } - - public boolean quitApp() throws IOException, XmlPullParserException { - String xmlStr = openHttpConnectionToString(httpClientLongConnectNoReadTimeout, getHttpsUrl(true), "cancel"); - if (getXmlString(xmlStr, "cancel", true).equals("0")) { - return false; - } - - // Newer GFE versions will just return success even if quitting fails - // if we're not the original requestor. - if (getCurrentGame(getServerInfo(true)) != 0) { - // Generate a synthetic GfeResponseException letting the caller know - // that they can't kill someone else's stream. - throw new HostHttpResponseException(599, ""); - } - - return true; - } -} +package com.limelight.nvstream.http; + +import android.os.Build; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.io.StringReader; +import java.net.Inet4Address; +import java.net.InetAddress; +import java.net.Proxy; +import java.net.Socket; +import java.security.KeyManagementException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.Principal; +import java.security.PrivateKey; +import java.security.SecureRandom; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.LinkedList; +import java.util.ListIterator; +import java.util.Stack; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.KeyManager; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLHandshakeException; +import javax.net.ssl.SSLPeerUnverifiedException; +import javax.net.ssl.SSLSession; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509KeyManager; +import javax.net.ssl.X509TrustManager; + +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; +import org.xmlpull.v1.XmlPullParserFactory; + +import com.limelight.BuildConfig; +import com.limelight.LimeLog; +import com.limelight.nvstream.ConnectionContext; +import com.limelight.nvstream.http.PairingManager.PairState; +import com.limelight.nvstream.jni.MoonBridge; + +import okhttp3.ConnectionPool; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; + + +public class NvHTTP { + private String uniqueId; + private PairingManager pm; + + private static final int DEFAULT_HTTPS_PORT = 47984; + public static final int DEFAULT_HTTP_PORT = 47989; + public static final int SHORT_CONNECTION_TIMEOUT = 3000; + public static final int LONG_CONNECTION_TIMEOUT = 5000; + public static final int READ_TIMEOUT = 7000; + + // Print URL and content to logcat on debug builds + private static boolean verbose = BuildConfig.DEBUG; + + private HttpUrl baseUrlHttp; + + private int httpsPort; + + private OkHttpClient httpClientLongConnectTimeout; + private OkHttpClient httpClientLongConnectNoReadTimeout; + private OkHttpClient httpClientShortConnectTimeout; + + private X509TrustManager defaultTrustManager; + private X509TrustManager trustManager; + private X509KeyManager keyManager; + private X509Certificate serverCert; + + void setServerCert(X509Certificate serverCert) { + this.serverCert = serverCert; + } + + private static X509TrustManager getDefaultTrustManager() { + try { + TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init((KeyStore) null); + + for (TrustManager tm : tmf.getTrustManagers()) { + if (tm instanceof X509TrustManager) { + return (X509TrustManager) tm; + } + } + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } catch (KeyStoreException e) { + throw new RuntimeException(e); + } + + throw new IllegalStateException("No X509 trust manager found"); + } + + private void initializeHttpState(final LimelightCryptoProvider cryptoProvider) { + keyManager = new X509KeyManager() { + public String chooseClientAlias(String[] keyTypes, + Principal[] issuers, Socket socket) { return "Limelight-RSA"; } + public String chooseServerAlias(String keyType, Principal[] issuers, + Socket socket) { return null; } + public X509Certificate[] getCertificateChain(String alias) { + return new X509Certificate[] {cryptoProvider.getClientCertificate()}; + } + public String[] getClientAliases(String keyType, Principal[] issuers) { return null; } + public PrivateKey getPrivateKey(String alias) { + return cryptoProvider.getClientPrivateKey(); + } + public String[] getServerAliases(String keyType, Principal[] issuers) { return null; } + }; + + defaultTrustManager = getDefaultTrustManager(); + trustManager = new X509TrustManager() { + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + public void checkClientTrusted(X509Certificate[] certs, String authType) { + throw new IllegalStateException("Should never be called"); + } + public void checkServerTrusted(X509Certificate[] certs, String authType) throws CertificateException { + try { + // Try the default trust manager first to allow pairing with certificates + // that chain up to a trusted root CA. This will raise CertificateException + // if the certificate is not trusted (expected for GFE's self-signed certs). + defaultTrustManager.checkServerTrusted(certs, authType); + } catch (CertificateException e) { + // Check the server certificate if we've paired to this host + if (certs.length == 1 && NvHTTP.this.serverCert != null) { + if (!certs[0].equals(NvHTTP.this.serverCert)) { + throw new CertificateException("Certificate mismatch"); + } + } + else { + // The cert chain doesn't look like a self-signed cert or we don't have + // a certificate pinned, so re-throw the original validation error. + throw e; + } + } + } + }; + + HostnameVerifier hv = new HostnameVerifier() { + public boolean verify(String hostname, SSLSession session) { + try { + Certificate[] certificates = session.getPeerCertificates(); + if (certificates.length == 1 && certificates[0].equals(NvHTTP.this.serverCert)) { + // Allow any hostname if it's our pinned cert + return true; + } + } catch (SSLPeerUnverifiedException e) { + e.printStackTrace(); + } + + // Fall back to default HostnameVerifier for validating CA-issued certs + return HttpsURLConnection.getDefaultHostnameVerifier().verify(hostname, session); + } + }; + + httpClientLongConnectTimeout = new OkHttpClient.Builder() + .connectionPool(new ConnectionPool(0, 1, TimeUnit.MILLISECONDS)) + .hostnameVerifier(hv) + .readTimeout(READ_TIMEOUT, TimeUnit.MILLISECONDS) + .connectTimeout(LONG_CONNECTION_TIMEOUT, TimeUnit.MILLISECONDS) + .proxy(Proxy.NO_PROXY) + .build(); + + httpClientShortConnectTimeout = httpClientLongConnectTimeout.newBuilder() + .connectTimeout(SHORT_CONNECTION_TIMEOUT, TimeUnit.MILLISECONDS) + .build(); + + httpClientLongConnectNoReadTimeout = httpClientLongConnectTimeout.newBuilder() + .readTimeout(0, TimeUnit.MILLISECONDS) + .build(); + } + + public HttpUrl getHttpsUrl(boolean likelyOnline) throws IOException { + if (httpsPort == 0) { + // Fetch the HTTPS port if we don't have it already + httpsPort = getHttpsPort(openHttpConnectionToString(likelyOnline ? httpClientLongConnectTimeout : httpClientShortConnectTimeout, + baseUrlHttp, "serverinfo")); + } + + return new HttpUrl.Builder().scheme("https").host(baseUrlHttp.host()).port(httpsPort).build(); + } + + public NvHTTP(ComputerDetails.AddressTuple address, int httpsPort, String uniqueId, X509Certificate serverCert, LimelightCryptoProvider cryptoProvider) throws IOException { + // Use the same UID for all Moonlight clients so we can quit games + // started by other Moonlight clients. + this.uniqueId = "0123456789ABCDEF"; + + this.serverCert = serverCert; + + initializeHttpState(cryptoProvider); + + this.httpsPort = httpsPort; + + try { + // If this is an IPv4-mapped IPv6 address, OkHTTP will choke on it if it's + // in IPv6 form, because InetAddress.getByName() will return an Inet4Address + // for what OkHTTP thinks is an IPv6 address. Normalize it into IPv4 form + // to avoid triggering this bug. + String addressString = address.address; + if (addressString.contains(":") && addressString.contains(".")) { + InetAddress addr = InetAddress.getByName(addressString); + if (addr instanceof Inet4Address) { + addressString = ((Inet4Address)addr).getHostAddress(); + } + } + + this.baseUrlHttp = new HttpUrl.Builder() + .scheme("http") + .host(addressString) + .port(address.port) + .build(); + } catch (IllegalArgumentException e) { + // Encapsulate IllegalArgumentException into IOException for callers to handle more easily + throw new IOException(e); + } + + this.pm = new PairingManager(this, cryptoProvider); + } + + static String getXmlString(Reader r, String tagname, boolean throwIfMissing) throws XmlPullParserException, IOException { + XmlPullParserFactory factory = XmlPullParserFactory.newInstance(); + factory.setNamespaceAware(true); + XmlPullParser xpp = factory.newPullParser(); + + xpp.setInput(r); + int eventType = xpp.getEventType(); + Stack currentTag = new Stack(); + + while (eventType != XmlPullParser.END_DOCUMENT) { + switch (eventType) { + case (XmlPullParser.START_TAG): + if (xpp.getName().equals("root")) { + verifyResponseStatus(xpp); + } + currentTag.push(xpp.getName()); + break; + case (XmlPullParser.END_TAG): + currentTag.pop(); + break; + case (XmlPullParser.TEXT): + if (currentTag.peek().equals(tagname)) { + return xpp.getText(); + } + break; + } + eventType = xpp.next(); + } + + if (throwIfMissing) { + // We throw an XmlPullParserException here for ease of handling in all the various callers. + // We could also throw an IOException, but some callers expect those in cases where the + // host may not be reachable. We want to distinguish unreachable hosts vs. hosts that + // are returning garbage XML to us, so we use XmlPullParserException instead. + throw new XmlPullParserException("Missing mandatory field in host response: "+tagname); + } + + return null; + } + + static String getXmlString(String str, String tagname, boolean throwIfMissing) throws XmlPullParserException, IOException { + return getXmlString(new StringReader(str), tagname, throwIfMissing); + } + + private static void verifyResponseStatus(XmlPullParser xpp) throws HostHttpResponseException { + // We use Long.parseLong() because in rare cases GFE can send back a status code of + // 0xFFFFFFFF, which will cause Integer.parseInt() to throw a NumberFormatException due + // to exceeding Integer.MAX_VALUE. We'll get the desired error code of -1 by just casting + // the resulting long into an int. + int statusCode = (int)Long.parseLong(xpp.getAttributeValue(XmlPullParser.NO_NAMESPACE, "status_code")); + if (statusCode != 200) { + String statusMsg = xpp.getAttributeValue(XmlPullParser.NO_NAMESPACE, "status_message"); + if (statusCode == -1 && "Invalid".equals(statusMsg)) { + // Special case handling an audio capture error which GFE doesn't + // provide any useful status message for. + statusCode = 418; + statusMsg = "Missing audio capture device. Reinstall GeForce Experience."; + } + throw new HostHttpResponseException(statusCode, statusMsg); + } + } + + public String getServerInfo(boolean likelyOnline) throws IOException, XmlPullParserException { + String resp; + + // If we believe the PC is online, give it a little extra time to respond + OkHttpClient client = likelyOnline ? httpClientLongConnectTimeout : httpClientShortConnectTimeout; + + // + // TODO: Shield Hub uses HTTP for this and is able to get an accurate PairStatus with HTTP. + // For some reason, we always see PairStatus is 0 over HTTP and only 1 over HTTPS. It looks + // like there are extra request headers required to make this stuff work over HTTP. + // + + // When we have a pinned cert, use HTTPS to fetch serverinfo and fall back on cert mismatch + if (serverCert != null) { + try { + try { + resp = openHttpConnectionToString(client, getHttpsUrl(likelyOnline), "serverinfo"); + } catch (SSLHandshakeException e) { + // Detect if we failed due to a server cert mismatch + if (e.getCause() instanceof CertificateException) { + // Jump to the GfeHttpResponseException exception handler to retry + // over HTTP which will allow us to pair again to update the cert + throw new HostHttpResponseException(401, "Server certificate mismatch"); + } + else { + throw e; + } + } + + // This will throw an exception if the request came back with a failure status. + // We want this because it will throw us into the HTTP case if the client is unpaired. + getServerVersion(resp); + } + catch (HostHttpResponseException e) { + if (e.getErrorCode() == 401) { + // Cert validation error - fall back to HTTP + return openHttpConnectionToString(client, baseUrlHttp, "serverinfo"); + } + + // If it's not a cert validation error, throw it + throw e; + } + + return resp; + } + else { + // No pinned cert, so use HTTP + return openHttpConnectionToString(client, baseUrlHttp, "serverinfo"); + } + } + + private static ComputerDetails.AddressTuple makeTuple(String address, int port) { + if (address == null) { + return null; + } + + return new ComputerDetails.AddressTuple(address, port); + } + + public ComputerDetails getComputerDetails(String serverInfo) throws IOException, XmlPullParserException { + ComputerDetails details = new ComputerDetails(); + + details.name = getXmlString(serverInfo, "hostname", false); + if (details.name == null || details.name.isEmpty()) { + details.name = "UNKNOWN"; + } + + // UUID is mandatory to determine which machine is responding + details.uuid = getXmlString(serverInfo, "uniqueid", true); + + details.httpsPort = getHttpsPort(serverInfo); + + details.macAddress = getXmlString(serverInfo, "mac", false); + + // FIXME: Do we want to use the current port? + details.localAddress = makeTuple(getXmlString(serverInfo, "LocalIP", false), baseUrlHttp.port()); + + // This is missing on on recent GFE versions, but it's present on Sunshine + details.externalPort = getExternalPort(serverInfo); + details.remoteAddress = makeTuple(getXmlString(serverInfo, "ExternalIP", false), details.externalPort); + + details.pairState = getPairState(serverInfo); + details.runningGameId = getCurrentGame(serverInfo); + + // The MJOLNIR codename was used by GFE but never by any third-party server + details.nvidiaServer = getXmlString(serverInfo, "state", true).contains("MJOLNIR"); + + // We could reach it so it's online + details.state = ComputerDetails.State.ONLINE; + + return details; + } + + public ComputerDetails getComputerDetails(boolean likelyOnline) throws IOException, XmlPullParserException { + return getComputerDetails(getServerInfo(likelyOnline)); + } + + // This hack is Android-specific but we do it on all platforms + // because it doesn't really matter + private OkHttpClient performAndroidTlsHack(OkHttpClient client) { + // Doing this each time we create a socket is required + // to avoid the SSLv3 fallback that causes connection failures + try { + SSLContext sc = SSLContext.getInstance("TLS"); + sc.init(new KeyManager[] { keyManager }, new TrustManager[] { trustManager }, new SecureRandom()); + return client.newBuilder().sslSocketFactory(sc.getSocketFactory(), trustManager).build(); + } catch (NoSuchAlgorithmException | KeyManagementException e) { + throw new RuntimeException(e); + } + } + + private HttpUrl getCompleteUrl(HttpUrl baseUrl, String path, String query) { + return baseUrl.newBuilder() + .addPathSegment(path) + .query(query) + .addQueryParameter("uniqueid", uniqueId) + .addQueryParameter("uuid", UUID.randomUUID().toString()) + .build(); + } + + private ResponseBody openHttpConnection(OkHttpClient client, HttpUrl baseUrl, String path) throws IOException { + return openHttpConnection(client, baseUrl, path, null); + } + + // Read timeout should be enabled for any HTTP query that requires no outside action + // on the GFE server. Examples of queries that DO require outside action are launch, resume, and quit. + // The initial pair query does require outside action (user entering a PIN) but subsequent pairing + // queries do not. + private ResponseBody openHttpConnection(OkHttpClient client, HttpUrl baseUrl, String path, String query) throws IOException { + HttpUrl completeUrl = getCompleteUrl(baseUrl, path, query); + Request request = new Request.Builder().url(completeUrl).get().build(); + Response response = performAndroidTlsHack(client).newCall(request).execute(); + + ResponseBody body = response.body(); + + if (response.isSuccessful()) { + return body; + } + + // Unsuccessful, so close the response body + if (body != null) { + body.close(); + } + + if (response.code() == 404) { + throw new FileNotFoundException(completeUrl.toString()); + } + else { + throw new HostHttpResponseException(response.code(), response.message()); + } + } + + private String openHttpConnectionToString(OkHttpClient client, HttpUrl baseUrl, String path) throws IOException { + return openHttpConnectionToString(client, baseUrl, path, null); + } + + private String openHttpConnectionToString(OkHttpClient client, HttpUrl baseUrl, String path, String query) throws IOException { + try { + ResponseBody resp = openHttpConnection(client, baseUrl, path, query); + String respString = resp.string(); + resp.close(); + + if (verbose && !path.equals("serverinfo")) { + LimeLog.info(getCompleteUrl(baseUrl, path, query)+" -> "+respString); + } + + return respString; + } catch (IOException e) { + if (verbose && !path.equals("serverinfo")) { + LimeLog.warning(getCompleteUrl(baseUrl, path, query)+" -> "+e.getMessage()); + e.printStackTrace(); + } + + throw e; + } + } + + public String getServerVersion(String serverInfo) throws XmlPullParserException, IOException { + // appversion is present in all supported GFE versions + return getXmlString(serverInfo, "appversion", true); + } + + public PairingManager.PairState getPairState() throws IOException, XmlPullParserException { + return getPairState(getServerInfo(true)); + } + + public PairingManager.PairState getPairState(String serverInfo) throws IOException, XmlPullParserException { + // appversion is present in all supported GFE versions + return NvHTTP.getXmlString(serverInfo, "PairStatus", true).equals("1") ? + PairState.PAIRED : PairState.NOT_PAIRED; + } + + public long getMaxLumaPixelsH264(String serverInfo) throws XmlPullParserException, IOException { + // MaxLumaPixelsH264 wasn't present on old GFE versions + String str = getXmlString(serverInfo, "MaxLumaPixelsH264", false); + if (str != null) { + return Long.parseLong(str); + } else { + return 0; + } + } + + public long getMaxLumaPixelsHEVC(String serverInfo) throws XmlPullParserException, IOException { + // MaxLumaPixelsHEVC wasn't present on old GFE versions + String str = getXmlString(serverInfo, "MaxLumaPixelsHEVC", false); + if (str != null) { + return Long.parseLong(str); + } else { + return 0; + } + } + + // Possible meaning of bits + // Bit 0: H.264 Baseline + // Bit 1: H.264 High + // ---- + // Bit 8: HEVC Main + // Bit 9: HEVC Main10 + // Bit 10: HEVC Main10 4:4:4 + // Bit 11: ??? + public long getServerCodecModeSupport(String serverInfo) throws XmlPullParserException, IOException { + // ServerCodecModeSupport wasn't present on old GFE versions + String str = getXmlString(serverInfo, "ServerCodecModeSupport", false); + if (str != null) { + return Long.parseLong(str); + } else { + return 0; + } + } + + public String getGpuType(String serverInfo) throws XmlPullParserException, IOException { + // ServerCodecModeSupport wasn't present on old GFE versions + return getXmlString(serverInfo, "gputype", false); + } + + public String getGfeVersion(String serverInfo) throws XmlPullParserException, IOException { + // ServerCodecModeSupport wasn't present on old GFE versions + return getXmlString(serverInfo, "GfeVersion", false); + } + + public boolean supports4K(String serverInfo) throws XmlPullParserException, IOException { + // Only allow 4K on GFE 3.x. GfeVersion wasn't present on very old versions of GFE. + String gfeVersionStr = getXmlString(serverInfo, "GfeVersion", false); + if (gfeVersionStr == null || gfeVersionStr.startsWith("2.")) { + return false; + } + + return true; + } + + public int getCurrentGame(String serverInfo) throws IOException, XmlPullParserException { + // GFE 2.8 started keeping currentgame set to the last game played. As a result, it no longer + // has the semantics that its name would indicate. To contain the effects of this change as much + // as possible, we'll force the current game to zero if the server isn't in a streaming session. + if (getXmlString(serverInfo, "state", true).endsWith("_SERVER_BUSY")) { + return Integer.parseInt(getXmlString(serverInfo, "currentgame", true)); + } + else { + return 0; + } + } + + public int getHttpsPort(String serverInfo) { + try { + return Integer.parseInt(getXmlString(serverInfo, "HttpsPort", true)); + } catch (XmlPullParserException e) { + e.printStackTrace(); + return DEFAULT_HTTPS_PORT; + } catch (IOException e) { + e.printStackTrace(); + return DEFAULT_HTTPS_PORT; + } + } + + public int getExternalPort(String serverInfo) { + // This is an extension which is not present in GFE. It is present for Sunshine to be able + // to support dynamic HTTP WAN ports without requiring the user to manually enter the port. + try { + return Integer.parseInt(getXmlString(serverInfo, "ExternalPort", true)); + } catch (XmlPullParserException e) { + // Expected on non-Sunshine servers + return baseUrlHttp.port(); + } catch (IOException e) { + e.printStackTrace(); + return baseUrlHttp.port(); + } + } + + public NvApp getAppById(int appId) throws IOException, XmlPullParserException { + LinkedList appList = getAppList(); + for (NvApp appFromList : appList) { + if (appFromList.getAppId() == appId) { + return appFromList; + } + } + return null; + } + + /* NOTE: Only use this function if you know what you're doing. + * It's totally valid to have two apps named the same thing, + * or even nothing at all! Look apps up by ID if at all possible + * using the above function */ + public NvApp getAppByName(String appName) throws IOException, XmlPullParserException { + LinkedList appList = getAppList(); + for (NvApp appFromList : appList) { + if (appFromList.getAppName().equalsIgnoreCase(appName)) { + return appFromList; + } + } + return null; + } + + public PairingManager getPairingManager() { + return pm; + } + + public static LinkedList getAppListByReader(Reader r) throws XmlPullParserException, IOException { + XmlPullParserFactory factory = XmlPullParserFactory.newInstance(); + factory.setNamespaceAware(true); + XmlPullParser xpp = factory.newPullParser(); + + xpp.setInput(r); + int eventType = xpp.getEventType(); + LinkedList appList = new LinkedList(); + Stack currentTag = new Stack(); + boolean rootTerminated = false; + + while (eventType != XmlPullParser.END_DOCUMENT) { + switch (eventType) { + case (XmlPullParser.START_TAG): + if (xpp.getName().equals("root")) { + verifyResponseStatus(xpp); + } + currentTag.push(xpp.getName()); + if (xpp.getName().equals("App")) { + appList.addLast(new NvApp()); + } + break; + case (XmlPullParser.END_TAG): + currentTag.pop(); + if (xpp.getName().equals("root")) { + rootTerminated = true; + } + break; + case (XmlPullParser.TEXT): + NvApp app = appList.getLast(); + if (currentTag.peek().equals("AppTitle")) { + app.setAppName(xpp.getText()); + } else if (currentTag.peek().equals("ID")) { + app.setAppId(xpp.getText()); + } else if (currentTag.peek().equals("IsHdrSupported")) { + app.setHdrSupported(xpp.getText().equals("1")); + } + break; + } + eventType = xpp.next(); + } + + // Throw a malformed XML exception if we've not seen the root tag ended + if (!rootTerminated) { + throw new XmlPullParserException("Malformed XML: Root tag was not terminated"); + } + + // Ensure that all apps in the list are initialized + ListIterator i = appList.listIterator(); + while (i.hasNext()) { + NvApp app = i.next(); + + // Remove uninitialized apps + if (!app.isInitialized()) { + LimeLog.warning("GFE returned incomplete app: "+app.getAppId()+" "+app.getAppName()); + i.remove(); + } + } + + return appList; + } + + public String getAppListRaw() throws IOException { + return openHttpConnectionToString(httpClientLongConnectTimeout, getHttpsUrl(true), "applist"); + } + + public LinkedList getAppList() throws HostHttpResponseException, IOException, XmlPullParserException { + if (verbose) { + // Use the raw function so the app list is printed + return getAppListByReader(new StringReader(getAppListRaw())); + } + else { + try (final ResponseBody resp = openHttpConnection(httpClientLongConnectTimeout, getHttpsUrl(true), "applist")) { + return getAppListByReader(new InputStreamReader(resp.byteStream())); + } + } + } + + String executePairingCommand(String additionalArguments, boolean enableReadTimeout) throws HostHttpResponseException, IOException { + return openHttpConnectionToString(enableReadTimeout ? httpClientLongConnectTimeout : httpClientLongConnectNoReadTimeout, + baseUrlHttp, "pair", "devicename=roth&updateState=1&" + additionalArguments); + } + + String executePairingChallenge() throws HostHttpResponseException, IOException { + return openHttpConnectionToString(httpClientLongConnectTimeout, getHttpsUrl(true), + "pair", "devicename=roth&updateState=1&phrase=pairchallenge"); + } + + public void unpair() throws IOException { + openHttpConnectionToString(httpClientLongConnectTimeout, baseUrlHttp, "unpair"); + } + + public InputStream getBoxArt(NvApp app) throws IOException { + ResponseBody resp = openHttpConnection(httpClientLongConnectTimeout, getHttpsUrl(true), "appasset", "appid=" + app.getAppId() + "&AssetType=2&AssetIdx=0"); + return resp.byteStream(); + } + + public int getServerMajorVersion(String serverInfo) throws XmlPullParserException, IOException { + return getServerAppVersionQuad(serverInfo)[0]; + } + + public int[] getServerAppVersionQuad(String serverInfo) throws XmlPullParserException, IOException { + String serverVersion = getServerVersion(serverInfo); + if (serverVersion == null) { + throw new IllegalArgumentException("Missing server version field"); + } + String[] serverVersionSplit = serverVersion.split("\\."); + if (serverVersionSplit.length != 4) { + throw new IllegalArgumentException("Malformed server version field: "+serverVersion); + } + int[] ret = new int[serverVersionSplit.length]; + for (int i = 0; i < ret.length; i++) { + ret[i] = Integer.parseInt(serverVersionSplit[i]); + } + return ret; + } + + final private static char[] hexArray = "0123456789ABCDEF".toCharArray(); + private static String bytesToHex(byte[] bytes) { + char[] hexChars = new char[bytes.length * 2]; + for ( int j = 0; j < bytes.length; j++ ) { + int v = bytes[j] & 0xFF; + hexChars[j * 2] = hexArray[v >>> 4]; + hexChars[j * 2 + 1] = hexArray[v & 0x0F]; + } + return new String(hexChars); + } + + public boolean launchApp(ConnectionContext context, String verb, int appId, boolean enableHdr) throws IOException, XmlPullParserException { + // Using an FPS value over 60 causes SOPS to default to 720p60, + // so force it to 0 to ensure the correct resolution is set. We + // used to use 60 here but that locked the frame rate to 60 FPS + // on GFE 3.20.3. + int fps = context.isNvidiaServerSoftware && context.streamConfig.getLaunchRefreshRate() > 60 ? + 0 : context.streamConfig.getLaunchRefreshRate(); + + boolean enableSops = context.streamConfig.getSops(); + if (context.isNvidiaServerSoftware) { + // Using an unsupported resolution (not 720p, 1080p, or 4K) causes + // GFE to force SOPS to 720p60. This is fine for < 720p resolutions like + // 360p or 480p, but it is not ideal for 1440p and other resolutions. + // When we detect an unsupported resolution, disable SOPS unless it's under 720p. + // FIXME: Detect support resolutions using the serverinfo response, not a hardcoded list + if (context.negotiatedWidth * context.negotiatedHeight > 1280 * 720 && + context.negotiatedWidth * context.negotiatedHeight != 1920 * 1080 && + context.negotiatedWidth * context.negotiatedHeight != 3840 * 2160) { + LimeLog.info("Disabling SOPS due to non-standard resolution: "+context.negotiatedWidth+"x"+context.negotiatedHeight); + enableSops = false; + } + } + + String xmlStr = openHttpConnectionToString(httpClientLongConnectNoReadTimeout, getHttpsUrl(true), verb, + "appid=" + appId + + "&mode=" + context.negotiatedWidth + "x" + context.negotiatedHeight + "x" + fps + + "&additionalStates=1&sops=" + (enableSops ? 1 : 0) + + "&rikey="+bytesToHex(context.riKey.getEncoded()) + + "&rikeyid="+context.riKeyId + + (!enableHdr ? "" : "&hdrMode=1&clientHdrCapVersion=0&clientHdrCapSupportedFlagsInUint32=0&clientHdrCapMetaDataId=NV_STATIC_METADATA_TYPE_1&clientHdrCapDisplayData=0x0x0x0x0x0x0x0x0x0x0") + + "&localAudioPlayMode=" + (context.streamConfig.getPlayLocalAudio() ? 1 : 0) + + "&surroundAudioInfo=" + context.streamConfig.getAudioConfiguration().getSurroundAudioInfo() + + "&remoteControllersBitmap=" + context.streamConfig.getAttachedGamepadMask() + + "&gcmap=" + context.streamConfig.getAttachedGamepadMask() + + "&gcpersist="+(context.streamConfig.getPersistGamepadsAfterDisconnect() ? 1 : 0) + + MoonBridge.getLaunchUrlQueryParameters()); + if ((verb.equals("launch") && !getXmlString(xmlStr, "gamesession", true).equals("0") || + (verb.equals("resume") && !getXmlString(xmlStr, "resume", true).equals("0")))) { + // sessionUrl0 will be missing for older GFE versions + context.rtspSessionUrl = getXmlString(xmlStr, "sessionUrl0", false); + return true; + } + else { + return false; + } + } + + public boolean quitApp() throws IOException, XmlPullParserException { + String xmlStr = openHttpConnectionToString(httpClientLongConnectNoReadTimeout, getHttpsUrl(true), "cancel"); + if (getXmlString(xmlStr, "cancel", true).equals("0")) { + return false; + } + + // Newer GFE versions will just return success even if quitting fails + // if we're not the original requestor. + if (getCurrentGame(getServerInfo(true)) != 0) { + // Generate a synthetic GfeResponseException letting the caller know + // that they can't kill someone else's stream. + throw new HostHttpResponseException(599, ""); + } + + return true; + } +} diff --git a/app/src/main/java/com/limelight/nvstream/http/PairingManager.java b/app/src/main/java/com/limelight/nvstream/http/PairingManager.java old mode 100644 new mode 100755 index 65994b61ca..7484f32a39 --- a/app/src/main/java/com/limelight/nvstream/http/PairingManager.java +++ b/app/src/main/java/com/limelight/nvstream/http/PairingManager.java @@ -1,334 +1,334 @@ -package com.limelight.nvstream.http; - -import org.bouncycastle.crypto.BlockCipher; -import org.bouncycastle.crypto.engines.AESLightEngine; -import org.bouncycastle.crypto.params.KeyParameter; - -import org.xmlpull.v1.XmlPullParserException; - -import com.limelight.LimeLog; - -import java.security.cert.Certificate; -import java.io.*; -import java.security.*; -import java.security.cert.*; -import java.util.Arrays; -import java.util.Locale; - -public class PairingManager { - - private NvHTTP http; - - private PrivateKey pk; - private X509Certificate cert; - private byte[] pemCertBytes; - - private X509Certificate serverCert; - - public enum PairState { - NOT_PAIRED, - PAIRED, - PIN_WRONG, - FAILED, - ALREADY_IN_PROGRESS - } - - public PairingManager(NvHTTP http, LimelightCryptoProvider cryptoProvider) { - this.http = http; - this.cert = cryptoProvider.getClientCertificate(); - this.pemCertBytes = cryptoProvider.getPemEncodedClientCertificate(); - this.pk = cryptoProvider.getClientPrivateKey(); - } - - final private static char[] hexArray = "0123456789ABCDEF".toCharArray(); - private static String bytesToHex(byte[] bytes) { - char[] hexChars = new char[bytes.length * 2]; - for ( int j = 0; j < bytes.length; j++ ) { - int v = bytes[j] & 0xFF; - hexChars[j * 2] = hexArray[v >>> 4]; - hexChars[j * 2 + 1] = hexArray[v & 0x0F]; - } - return new String(hexChars); - } - - private static byte[] hexToBytes(String s) { - int len = s.length(); - if (len % 2 != 0) { - throw new IllegalArgumentException("Illegal string length: "+len); - } - - byte[] data = new byte[len / 2]; - for (int i = 0; i < len; i += 2) { - data[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4) - + Character.digit(s.charAt(i+1), 16)); - } - return data; - } - - private X509Certificate extractPlainCert(String text) throws XmlPullParserException, IOException - { - // Plaincert may be null if another client is already trying to pair - String certText = NvHTTP.getXmlString(text, "plaincert", false); - if (certText != null) { - byte[] certBytes = hexToBytes(certText); - - try { - CertificateFactory cf = CertificateFactory.getInstance("X.509"); - return (X509Certificate)cf.generateCertificate(new ByteArrayInputStream(certBytes)); - } catch (CertificateException e) { - e.printStackTrace(); - throw new RuntimeException(e); - } - } - else { - return null; - } - } - - private byte[] generateRandomBytes(int length) - { - byte[] rand = new byte[length]; - new SecureRandom().nextBytes(rand); - return rand; - } - - private static byte[] saltPin(byte[] salt, String pin) throws UnsupportedEncodingException { - byte[] saltedPin = new byte[salt.length + pin.length()]; - System.arraycopy(salt, 0, saltedPin, 0, salt.length); - System.arraycopy(pin.getBytes("UTF-8"), 0, saltedPin, salt.length, pin.length()); - return saltedPin; - } - - private static Signature getSha256SignatureInstanceForKey(Key key) throws NoSuchAlgorithmException { - switch (key.getAlgorithm()) { - case "RSA": - return Signature.getInstance("SHA256withRSA"); - case "EC": - return Signature.getInstance("SHA256withECDSA"); - default: - throw new NoSuchAlgorithmException("Unhandled key algorithm: " + key.getAlgorithm()); - } - } - - private static boolean verifySignature(byte[] data, byte[] signature, Certificate cert) { - try { - Signature sig = PairingManager.getSha256SignatureInstanceForKey(cert.getPublicKey()); - sig.initVerify(cert.getPublicKey()); - sig.update(data); - return sig.verify(signature); - } catch (NoSuchAlgorithmException | SignatureException | InvalidKeyException e) { - e.printStackTrace(); - throw new RuntimeException(e); - } - } - - private static byte[] signData(byte[] data, PrivateKey key) { - try { - Signature sig = PairingManager.getSha256SignatureInstanceForKey(key); - sig.initSign(key); - sig.update(data); - return sig.sign(); - } catch (NoSuchAlgorithmException | SignatureException | InvalidKeyException e) { - e.printStackTrace(); - throw new RuntimeException(e); - } - } - - private static byte[] performBlockCipher(BlockCipher blockCipher, byte[] input) { - int blockSize = blockCipher.getBlockSize(); - int blockRoundedSize = (input.length + (blockSize - 1)) & ~(blockSize - 1); - - byte[] blockRoundedInputData = Arrays.copyOf(input, blockRoundedSize); - byte[] blockRoundedOutputData = new byte[blockRoundedSize]; - - for (int offset = 0; offset < blockRoundedSize; offset += blockSize) { - blockCipher.processBlock(blockRoundedInputData, offset, blockRoundedOutputData, offset); - } - - return blockRoundedOutputData; - } - - private static byte[] decryptAes(byte[] encryptedData, byte[] aesKey) { - BlockCipher aesEngine = new AESLightEngine(); - aesEngine.init(false, new KeyParameter(aesKey)); - return performBlockCipher(aesEngine, encryptedData); - } - - private static byte[] encryptAes(byte[] plaintextData, byte[] aesKey) { - BlockCipher aesEngine = new AESLightEngine(); - aesEngine.init(true, new KeyParameter(aesKey)); - return performBlockCipher(aesEngine, plaintextData); - } - - private static byte[] generateAesKey(PairingHashAlgorithm hashAlgo, byte[] keyData) { - return Arrays.copyOf(hashAlgo.hashData(keyData), 16); - } - - private static byte[] concatBytes(byte[] a, byte[] b) { - byte[] c = new byte[a.length + b.length]; - System.arraycopy(a, 0, c, 0, a.length); - System.arraycopy(b, 0, c, a.length, b.length); - return c; - } - - public static String generatePinString() { - SecureRandom r = new SecureRandom(); - return String.format((Locale)null, "%d%d%d%d", - r.nextInt(10), r.nextInt(10), - r.nextInt(10), r.nextInt(10)); - } - - public X509Certificate getPairedCert() { - return serverCert; - } - - public PairState pair(String serverInfo, String pin) throws IOException, XmlPullParserException { - PairingHashAlgorithm hashAlgo; - - int serverMajorVersion = http.getServerMajorVersion(serverInfo); - LimeLog.info("Pairing with server generation: "+serverMajorVersion); - if (serverMajorVersion >= 7) { - // Gen 7+ uses SHA-256 hashing - hashAlgo = new Sha256PairingHash(); - } - else { - // Prior to Gen 7, SHA-1 is used - hashAlgo = new Sha1PairingHash(); - } - - // Generate a salt for hashing the PIN - byte[] salt = generateRandomBytes(16); - - // Combine the salt and pin, then create an AES key from them - byte[] aesKey = generateAesKey(hashAlgo, saltPin(salt, pin)); - - // Send the salt and get the server cert. This doesn't have a read timeout - // because the user must enter the PIN before the server responds - String getCert = http.executePairingCommand("phrase=getservercert&salt="+ - bytesToHex(salt)+"&clientcert="+bytesToHex(pemCertBytes), - false); - if (!NvHTTP.getXmlString(getCert, "paired", true).equals("1")) { - return PairState.FAILED; - } - - // Save this cert for retrieval later - serverCert = extractPlainCert(getCert); - if (serverCert == null) { - // Attempting to pair while another device is pairing will cause GFE - // to give an empty cert in the response. - http.unpair(); - return PairState.ALREADY_IN_PROGRESS; - } - - // Require this cert for TLS to this host - http.setServerCert(serverCert); - - // Generate a random challenge and encrypt it with our AES key - byte[] randomChallenge = generateRandomBytes(16); - byte[] encryptedChallenge = encryptAes(randomChallenge, aesKey); - - // Send the encrypted challenge to the server - String challengeResp = http.executePairingCommand("clientchallenge="+bytesToHex(encryptedChallenge), true); - if (!NvHTTP.getXmlString(challengeResp, "paired", true).equals("1")) { - http.unpair(); - return PairState.FAILED; - } - - // Decode the server's response and subsequent challenge - byte[] encServerChallengeResponse = hexToBytes(NvHTTP.getXmlString(challengeResp, "challengeresponse", true)); - byte[] decServerChallengeResponse = decryptAes(encServerChallengeResponse, aesKey); - - byte[] serverResponse = Arrays.copyOfRange(decServerChallengeResponse, 0, hashAlgo.getHashLength()); - byte[] serverChallenge = Arrays.copyOfRange(decServerChallengeResponse, hashAlgo.getHashLength(), hashAlgo.getHashLength() + 16); - - // Using another 16 bytes secret, compute a challenge response hash using the secret, our cert sig, and the challenge - byte[] clientSecret = generateRandomBytes(16); - byte[] challengeRespHash = hashAlgo.hashData(concatBytes(concatBytes(serverChallenge, cert.getSignature()), clientSecret)); - byte[] challengeRespEncrypted = encryptAes(challengeRespHash, aesKey); - String secretResp = http.executePairingCommand("serverchallengeresp="+bytesToHex(challengeRespEncrypted), true); - if (!NvHTTP.getXmlString(secretResp, "paired", true).equals("1")) { - http.unpair(); - return PairState.FAILED; - } - - // Get the server's signed secret - byte[] serverSecretResp = hexToBytes(NvHTTP.getXmlString(secretResp, "pairingsecret", true)); - byte[] serverSecret = Arrays.copyOfRange(serverSecretResp, 0, 16); - byte[] serverSignature = Arrays.copyOfRange(serverSecretResp, 16, serverSecretResp.length); - - // Ensure the authenticity of the data - if (!verifySignature(serverSecret, serverSignature, serverCert)) { - // Cancel the pairing process - http.unpair(); - - // Looks like a MITM - return PairState.FAILED; - } - - // Ensure the server challenge matched what we expected (aka the PIN was correct) - byte[] serverChallengeRespHash = hashAlgo.hashData(concatBytes(concatBytes(randomChallenge, serverCert.getSignature()), serverSecret)); - if (!Arrays.equals(serverChallengeRespHash, serverResponse)) { - // Cancel the pairing process - http.unpair(); - - // Probably got the wrong PIN - return PairState.PIN_WRONG; - } - - // Send the server our signed secret - byte[] clientPairingSecret = concatBytes(clientSecret, signData(clientSecret, pk)); - String clientSecretResp = http.executePairingCommand("clientpairingsecret="+bytesToHex(clientPairingSecret), true); - if (!NvHTTP.getXmlString(clientSecretResp, "paired", true).equals("1")) { - http.unpair(); - return PairState.FAILED; - } - - // Do the initial challenge (seems necessary for us to show as paired) - String pairChallenge = http.executePairingChallenge(); - if (!NvHTTP.getXmlString(pairChallenge, "paired", true).equals("1")) { - http.unpair(); - return PairState.FAILED; - } - - return PairState.PAIRED; - } - - private interface PairingHashAlgorithm { - int getHashLength(); - byte[] hashData(byte[] data); - } - - private static class Sha1PairingHash implements PairingHashAlgorithm { - public int getHashLength() { - return 20; - } - - public byte[] hashData(byte[] data) { - try { - MessageDigest md = MessageDigest.getInstance("SHA-1"); - return md.digest(data); - } - catch (NoSuchAlgorithmException e) { - e.printStackTrace(); - throw new RuntimeException(e); - } - } - } - - private static class Sha256PairingHash implements PairingHashAlgorithm { - public int getHashLength() { - return 32; - } - - public byte[] hashData(byte[] data) { - try { - MessageDigest md = MessageDigest.getInstance("SHA-256"); - return md.digest(data); - } - catch (NoSuchAlgorithmException e) { - e.printStackTrace(); - throw new RuntimeException(e); - } - } - } -} +package com.limelight.nvstream.http; + +import org.bouncycastle.crypto.BlockCipher; +import org.bouncycastle.crypto.engines.AESLightEngine; +import org.bouncycastle.crypto.params.KeyParameter; + +import org.xmlpull.v1.XmlPullParserException; + +import com.limelight.LimeLog; + +import java.security.cert.Certificate; +import java.io.*; +import java.security.*; +import java.security.cert.*; +import java.util.Arrays; +import java.util.Locale; + +public class PairingManager { + + private NvHTTP http; + + private PrivateKey pk; + private X509Certificate cert; + private byte[] pemCertBytes; + + private X509Certificate serverCert; + + public enum PairState { + NOT_PAIRED, + PAIRED, + PIN_WRONG, + FAILED, + ALREADY_IN_PROGRESS + } + + public PairingManager(NvHTTP http, LimelightCryptoProvider cryptoProvider) { + this.http = http; + this.cert = cryptoProvider.getClientCertificate(); + this.pemCertBytes = cryptoProvider.getPemEncodedClientCertificate(); + this.pk = cryptoProvider.getClientPrivateKey(); + } + + final private static char[] hexArray = "0123456789ABCDEF".toCharArray(); + private static String bytesToHex(byte[] bytes) { + char[] hexChars = new char[bytes.length * 2]; + for ( int j = 0; j < bytes.length; j++ ) { + int v = bytes[j] & 0xFF; + hexChars[j * 2] = hexArray[v >>> 4]; + hexChars[j * 2 + 1] = hexArray[v & 0x0F]; + } + return new String(hexChars); + } + + private static byte[] hexToBytes(String s) { + int len = s.length(); + if (len % 2 != 0) { + throw new IllegalArgumentException("Illegal string length: "+len); + } + + byte[] data = new byte[len / 2]; + for (int i = 0; i < len; i += 2) { + data[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4) + + Character.digit(s.charAt(i+1), 16)); + } + return data; + } + + private X509Certificate extractPlainCert(String text) throws XmlPullParserException, IOException + { + // Plaincert may be null if another client is already trying to pair + String certText = NvHTTP.getXmlString(text, "plaincert", false); + if (certText != null) { + byte[] certBytes = hexToBytes(certText); + + try { + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + return (X509Certificate)cf.generateCertificate(new ByteArrayInputStream(certBytes)); + } catch (CertificateException e) { + e.printStackTrace(); + throw new RuntimeException(e); + } + } + else { + return null; + } + } + + private byte[] generateRandomBytes(int length) + { + byte[] rand = new byte[length]; + new SecureRandom().nextBytes(rand); + return rand; + } + + private static byte[] saltPin(byte[] salt, String pin) throws UnsupportedEncodingException { + byte[] saltedPin = new byte[salt.length + pin.length()]; + System.arraycopy(salt, 0, saltedPin, 0, salt.length); + System.arraycopy(pin.getBytes("UTF-8"), 0, saltedPin, salt.length, pin.length()); + return saltedPin; + } + + private static Signature getSha256SignatureInstanceForKey(Key key) throws NoSuchAlgorithmException { + switch (key.getAlgorithm()) { + case "RSA": + return Signature.getInstance("SHA256withRSA"); + case "EC": + return Signature.getInstance("SHA256withECDSA"); + default: + throw new NoSuchAlgorithmException("Unhandled key algorithm: " + key.getAlgorithm()); + } + } + + private static boolean verifySignature(byte[] data, byte[] signature, Certificate cert) { + try { + Signature sig = PairingManager.getSha256SignatureInstanceForKey(cert.getPublicKey()); + sig.initVerify(cert.getPublicKey()); + sig.update(data); + return sig.verify(signature); + } catch (NoSuchAlgorithmException | SignatureException | InvalidKeyException e) { + e.printStackTrace(); + throw new RuntimeException(e); + } + } + + private static byte[] signData(byte[] data, PrivateKey key) { + try { + Signature sig = PairingManager.getSha256SignatureInstanceForKey(key); + sig.initSign(key); + sig.update(data); + return sig.sign(); + } catch (NoSuchAlgorithmException | SignatureException | InvalidKeyException e) { + e.printStackTrace(); + throw new RuntimeException(e); + } + } + + private static byte[] performBlockCipher(BlockCipher blockCipher, byte[] input) { + int blockSize = blockCipher.getBlockSize(); + int blockRoundedSize = (input.length + (blockSize - 1)) & ~(blockSize - 1); + + byte[] blockRoundedInputData = Arrays.copyOf(input, blockRoundedSize); + byte[] blockRoundedOutputData = new byte[blockRoundedSize]; + + for (int offset = 0; offset < blockRoundedSize; offset += blockSize) { + blockCipher.processBlock(blockRoundedInputData, offset, blockRoundedOutputData, offset); + } + + return blockRoundedOutputData; + } + + private static byte[] decryptAes(byte[] encryptedData, byte[] aesKey) { + BlockCipher aesEngine = new AESLightEngine(); + aesEngine.init(false, new KeyParameter(aesKey)); + return performBlockCipher(aesEngine, encryptedData); + } + + private static byte[] encryptAes(byte[] plaintextData, byte[] aesKey) { + BlockCipher aesEngine = new AESLightEngine(); + aesEngine.init(true, new KeyParameter(aesKey)); + return performBlockCipher(aesEngine, plaintextData); + } + + private static byte[] generateAesKey(PairingHashAlgorithm hashAlgo, byte[] keyData) { + return Arrays.copyOf(hashAlgo.hashData(keyData), 16); + } + + private static byte[] concatBytes(byte[] a, byte[] b) { + byte[] c = new byte[a.length + b.length]; + System.arraycopy(a, 0, c, 0, a.length); + System.arraycopy(b, 0, c, a.length, b.length); + return c; + } + + public static String generatePinString() { + SecureRandom r = new SecureRandom(); + return String.format((Locale)null, "%d%d%d%d", + r.nextInt(10), r.nextInt(10), + r.nextInt(10), r.nextInt(10)); + } + + public X509Certificate getPairedCert() { + return serverCert; + } + + public PairState pair(String serverInfo, String pin) throws IOException, XmlPullParserException { + PairingHashAlgorithm hashAlgo; + + int serverMajorVersion = http.getServerMajorVersion(serverInfo); + LimeLog.info("Pairing with server generation: "+serverMajorVersion); + if (serverMajorVersion >= 7) { + // Gen 7+ uses SHA-256 hashing + hashAlgo = new Sha256PairingHash(); + } + else { + // Prior to Gen 7, SHA-1 is used + hashAlgo = new Sha1PairingHash(); + } + + // Generate a salt for hashing the PIN + byte[] salt = generateRandomBytes(16); + + // Combine the salt and pin, then create an AES key from them + byte[] aesKey = generateAesKey(hashAlgo, saltPin(salt, pin)); + + // Send the salt and get the server cert. This doesn't have a read timeout + // because the user must enter the PIN before the server responds + String getCert = http.executePairingCommand("phrase=getservercert&salt="+ + bytesToHex(salt)+"&clientcert="+bytesToHex(pemCertBytes), + false); + if (!NvHTTP.getXmlString(getCert, "paired", true).equals("1")) { + return PairState.FAILED; + } + + // Save this cert for retrieval later + serverCert = extractPlainCert(getCert); + if (serverCert == null) { + // Attempting to pair while another device is pairing will cause GFE + // to give an empty cert in the response. + http.unpair(); + return PairState.ALREADY_IN_PROGRESS; + } + + // Require this cert for TLS to this host + http.setServerCert(serverCert); + + // Generate a random challenge and encrypt it with our AES key + byte[] randomChallenge = generateRandomBytes(16); + byte[] encryptedChallenge = encryptAes(randomChallenge, aesKey); + + // Send the encrypted challenge to the server + String challengeResp = http.executePairingCommand("clientchallenge="+bytesToHex(encryptedChallenge), true); + if (!NvHTTP.getXmlString(challengeResp, "paired", true).equals("1")) { + http.unpair(); + return PairState.FAILED; + } + + // Decode the server's response and subsequent challenge + byte[] encServerChallengeResponse = hexToBytes(NvHTTP.getXmlString(challengeResp, "challengeresponse", true)); + byte[] decServerChallengeResponse = decryptAes(encServerChallengeResponse, aesKey); + + byte[] serverResponse = Arrays.copyOfRange(decServerChallengeResponse, 0, hashAlgo.getHashLength()); + byte[] serverChallenge = Arrays.copyOfRange(decServerChallengeResponse, hashAlgo.getHashLength(), hashAlgo.getHashLength() + 16); + + // Using another 16 bytes secret, compute a challenge response hash using the secret, our cert sig, and the challenge + byte[] clientSecret = generateRandomBytes(16); + byte[] challengeRespHash = hashAlgo.hashData(concatBytes(concatBytes(serverChallenge, cert.getSignature()), clientSecret)); + byte[] challengeRespEncrypted = encryptAes(challengeRespHash, aesKey); + String secretResp = http.executePairingCommand("serverchallengeresp="+bytesToHex(challengeRespEncrypted), true); + if (!NvHTTP.getXmlString(secretResp, "paired", true).equals("1")) { + http.unpair(); + return PairState.FAILED; + } + + // Get the server's signed secret + byte[] serverSecretResp = hexToBytes(NvHTTP.getXmlString(secretResp, "pairingsecret", true)); + byte[] serverSecret = Arrays.copyOfRange(serverSecretResp, 0, 16); + byte[] serverSignature = Arrays.copyOfRange(serverSecretResp, 16, serverSecretResp.length); + + // Ensure the authenticity of the data + if (!verifySignature(serverSecret, serverSignature, serverCert)) { + // Cancel the pairing process + http.unpair(); + + // Looks like a MITM + return PairState.FAILED; + } + + // Ensure the server challenge matched what we expected (aka the PIN was correct) + byte[] serverChallengeRespHash = hashAlgo.hashData(concatBytes(concatBytes(randomChallenge, serverCert.getSignature()), serverSecret)); + if (!Arrays.equals(serverChallengeRespHash, serverResponse)) { + // Cancel the pairing process + http.unpair(); + + // Probably got the wrong PIN + return PairState.PIN_WRONG; + } + + // Send the server our signed secret + byte[] clientPairingSecret = concatBytes(clientSecret, signData(clientSecret, pk)); + String clientSecretResp = http.executePairingCommand("clientpairingsecret="+bytesToHex(clientPairingSecret), true); + if (!NvHTTP.getXmlString(clientSecretResp, "paired", true).equals("1")) { + http.unpair(); + return PairState.FAILED; + } + + // Do the initial challenge (seems necessary for us to show as paired) + String pairChallenge = http.executePairingChallenge(); + if (!NvHTTP.getXmlString(pairChallenge, "paired", true).equals("1")) { + http.unpair(); + return PairState.FAILED; + } + + return PairState.PAIRED; + } + + private interface PairingHashAlgorithm { + int getHashLength(); + byte[] hashData(byte[] data); + } + + private static class Sha1PairingHash implements PairingHashAlgorithm { + public int getHashLength() { + return 20; + } + + public byte[] hashData(byte[] data) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-1"); + return md.digest(data); + } + catch (NoSuchAlgorithmException e) { + e.printStackTrace(); + throw new RuntimeException(e); + } + } + } + + private static class Sha256PairingHash implements PairingHashAlgorithm { + public int getHashLength() { + return 32; + } + + public byte[] hashData(byte[] data) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + return md.digest(data); + } + catch (NoSuchAlgorithmException e) { + e.printStackTrace(); + throw new RuntimeException(e); + } + } + } +} diff --git a/app/src/main/java/com/limelight/nvstream/input/ControllerPacket.java b/app/src/main/java/com/limelight/nvstream/input/ControllerPacket.java old mode 100644 new mode 100755 index b956598b95..16ef589b31 --- a/app/src/main/java/com/limelight/nvstream/input/ControllerPacket.java +++ b/app/src/main/java/com/limelight/nvstream/input/ControllerPacket.java @@ -1,27 +1,27 @@ -package com.limelight.nvstream.input; - -public class ControllerPacket { - public static final int A_FLAG = 0x1000; - public static final int B_FLAG = 0x2000; - public static final int X_FLAG = 0x4000; - public static final int Y_FLAG = 0x8000; - public static final int UP_FLAG = 0x0001; - public static final int DOWN_FLAG = 0x0002; - public static final int LEFT_FLAG = 0x0004; - public static final int RIGHT_FLAG = 0x0008; - public static final int LB_FLAG = 0x0100; - public static final int RB_FLAG = 0x0200; - public static final int PLAY_FLAG = 0x0010; - public static final int BACK_FLAG = 0x0020; - public static final int LS_CLK_FLAG = 0x0040; - public static final int RS_CLK_FLAG = 0x0080; - public static final int SPECIAL_BUTTON_FLAG = 0x0400; - - // Extended buttons (Sunshine only) - public static final int PADDLE1_FLAG = 0x010000; - public static final int PADDLE2_FLAG = 0x020000; - public static final int PADDLE3_FLAG = 0x040000; - public static final int PADDLE4_FLAG = 0x080000; - public static final int TOUCHPAD_FLAG = 0x100000; // Touchpad buttons on Sony controllers - public static final int MISC_FLAG = 0x200000; // Share/Mic/Capture/Mute buttons on various controllers +package com.limelight.nvstream.input; + +public class ControllerPacket { + public static final int A_FLAG = 0x1000; + public static final int B_FLAG = 0x2000; + public static final int X_FLAG = 0x4000; + public static final int Y_FLAG = 0x8000; + public static final int UP_FLAG = 0x0001; + public static final int DOWN_FLAG = 0x0002; + public static final int LEFT_FLAG = 0x0004; + public static final int RIGHT_FLAG = 0x0008; + public static final int LB_FLAG = 0x0100; + public static final int RB_FLAG = 0x0200; + public static final int PLAY_FLAG = 0x0010; + public static final int BACK_FLAG = 0x0020; + public static final int LS_CLK_FLAG = 0x0040; + public static final int RS_CLK_FLAG = 0x0080; + public static final int SPECIAL_BUTTON_FLAG = 0x0400; + + // Extended buttons (Sunshine only) + public static final int PADDLE1_FLAG = 0x010000; + public static final int PADDLE2_FLAG = 0x020000; + public static final int PADDLE3_FLAG = 0x040000; + public static final int PADDLE4_FLAG = 0x080000; + public static final int TOUCHPAD_FLAG = 0x100000; // Touchpad buttons on Sony controllers + public static final int MISC_FLAG = 0x200000; // Share/Mic/Capture/Mute buttons on various controllers } \ No newline at end of file diff --git a/app/src/main/java/com/limelight/nvstream/input/KeyboardPacket.java b/app/src/main/java/com/limelight/nvstream/input/KeyboardPacket.java old mode 100644 new mode 100755 index 0b9cfff53c..b363a53a1b --- a/app/src/main/java/com/limelight/nvstream/input/KeyboardPacket.java +++ b/app/src/main/java/com/limelight/nvstream/input/KeyboardPacket.java @@ -1,11 +1,11 @@ -package com.limelight.nvstream.input; - -public class KeyboardPacket { - public static final byte KEY_DOWN = 0x03; - public static final byte KEY_UP = 0x04; - - public static final byte MODIFIER_SHIFT = 0x01; - public static final byte MODIFIER_CTRL = 0x02; - public static final byte MODIFIER_ALT = 0x04; - public static final byte MODIFIER_META = 0x08; +package com.limelight.nvstream.input; + +public class KeyboardPacket { + public static final byte KEY_DOWN = 0x03; + public static final byte KEY_UP = 0x04; + + public static final byte MODIFIER_SHIFT = 0x01; + public static final byte MODIFIER_CTRL = 0x02; + public static final byte MODIFIER_ALT = 0x04; + public static final byte MODIFIER_META = 0x08; } \ No newline at end of file diff --git a/app/src/main/java/com/limelight/nvstream/input/MouseButtonPacket.java b/app/src/main/java/com/limelight/nvstream/input/MouseButtonPacket.java old mode 100644 new mode 100755 index 1d06e043da..f199df3502 --- a/app/src/main/java/com/limelight/nvstream/input/MouseButtonPacket.java +++ b/app/src/main/java/com/limelight/nvstream/input/MouseButtonPacket.java @@ -1,12 +1,12 @@ -package com.limelight.nvstream.input; - -public class MouseButtonPacket { - public static final byte PRESS_EVENT = 0x07; - public static final byte RELEASE_EVENT = 0x08; - - public static final byte BUTTON_LEFT = 0x01; - public static final byte BUTTON_MIDDLE = 0x02; - public static final byte BUTTON_RIGHT = 0x03; - public static final byte BUTTON_X1 = 0x04; - public static final byte BUTTON_X2 = 0x05; -} +package com.limelight.nvstream.input; + +public class MouseButtonPacket { + public static final byte PRESS_EVENT = 0x07; + public static final byte RELEASE_EVENT = 0x08; + + public static final byte BUTTON_LEFT = 0x01; + public static final byte BUTTON_MIDDLE = 0x02; + public static final byte BUTTON_RIGHT = 0x03; + public static final byte BUTTON_X1 = 0x04; + public static final byte BUTTON_X2 = 0x05; +} diff --git a/app/src/main/java/com/limelight/nvstream/jni/MoonBridge.java b/app/src/main/java/com/limelight/nvstream/jni/MoonBridge.java old mode 100644 new mode 100755 index 0a92ac9162..066add33c9 --- a/app/src/main/java/com/limelight/nvstream/jni/MoonBridge.java +++ b/app/src/main/java/com/limelight/nvstream/jni/MoonBridge.java @@ -1,420 +1,422 @@ -package com.limelight.nvstream.jni; - -import com.limelight.nvstream.NvConnectionListener; -import com.limelight.nvstream.av.audio.AudioRenderer; -import com.limelight.nvstream.av.video.VideoDecoderRenderer; - -public class MoonBridge { - /* See documentation in Limelight.h for information about these functions and constants */ - - public static final AudioConfiguration AUDIO_CONFIGURATION_STEREO = new AudioConfiguration(2, 0x3); - public static final AudioConfiguration AUDIO_CONFIGURATION_51_SURROUND = new AudioConfiguration(6, 0x3F); - public static final AudioConfiguration AUDIO_CONFIGURATION_71_SURROUND = new AudioConfiguration(8, 0x63F); - - public static final int VIDEO_FORMAT_H264 = 0x0001; - public static final int VIDEO_FORMAT_H265 = 0x0100; - public static final int VIDEO_FORMAT_H265_MAIN10 = 0x0200; - public static final int VIDEO_FORMAT_AV1_MAIN8 = 0x1000; - public static final int VIDEO_FORMAT_AV1_MAIN10 = 0x2000; - - public static final int VIDEO_FORMAT_MASK_H264 = 0x000F; - public static final int VIDEO_FORMAT_MASK_H265 = 0x0F00; - public static final int VIDEO_FORMAT_MASK_AV1 = 0xF000; - public static final int VIDEO_FORMAT_MASK_10BIT = 0x2200; - - public static final int BUFFER_TYPE_PICDATA = 0; - public static final int BUFFER_TYPE_SPS = 1; - public static final int BUFFER_TYPE_PPS = 2; - public static final int BUFFER_TYPE_VPS = 3; - - public static final int FRAME_TYPE_PFRAME = 0; - public static final int FRAME_TYPE_IDR = 1; - - public static final int COLORSPACE_REC_601 = 0; - public static final int COLORSPACE_REC_709 = 1; - public static final int COLORSPACE_REC_2020 = 2; - - public static final int COLOR_RANGE_LIMITED = 0; - public static final int COLOR_RANGE_FULL = 1; - - public static final int CAPABILITY_DIRECT_SUBMIT = 1; - public static final int CAPABILITY_REFERENCE_FRAME_INVALIDATION_AVC = 2; - public static final int CAPABILITY_REFERENCE_FRAME_INVALIDATION_HEVC = 4; - public static final int CAPABILITY_REFERENCE_FRAME_INVALIDATION_AV1 = 0x40; - - public static final int DR_OK = 0; - public static final int DR_NEED_IDR = -1; - - public static final int CONN_STATUS_OKAY = 0; - public static final int CONN_STATUS_POOR = 1; - - public static final int ML_ERROR_GRACEFUL_TERMINATION = 0; - public static final int ML_ERROR_NO_VIDEO_TRAFFIC = -100; - public static final int ML_ERROR_NO_VIDEO_FRAME = -101; - public static final int ML_ERROR_UNEXPECTED_EARLY_TERMINATION = -102; - public static final int ML_ERROR_PROTECTED_CONTENT = -103; - public static final int ML_ERROR_FRAME_CONVERSION = -104; - - public static final int ML_PORT_INDEX_TCP_47984 = 0; - public static final int ML_PORT_INDEX_TCP_47989 = 1; - public static final int ML_PORT_INDEX_TCP_48010 = 2; - public static final int ML_PORT_INDEX_UDP_47998 = 8; - public static final int ML_PORT_INDEX_UDP_47999 = 9; - public static final int ML_PORT_INDEX_UDP_48000 = 10; - public static final int ML_PORT_INDEX_UDP_48010 = 11; - - public static final int ML_PORT_FLAG_ALL = 0xFFFFFFFF; - public static final int ML_PORT_FLAG_TCP_47984 = 0x0001; - public static final int ML_PORT_FLAG_TCP_47989 = 0x0002; - public static final int ML_PORT_FLAG_TCP_48010 = 0x0004; - public static final int ML_PORT_FLAG_UDP_47998 = 0x0100; - public static final int ML_PORT_FLAG_UDP_47999 = 0x0200; - public static final int ML_PORT_FLAG_UDP_48000 = 0x0400; - public static final int ML_PORT_FLAG_UDP_48010 = 0x0800; - - public static final int ML_TEST_RESULT_INCONCLUSIVE = 0xFFFFFFFF; - - public static final byte SS_KBE_FLAG_NON_NORMALIZED = 0x01; - - public static final int LI_ERR_UNSUPPORTED = -5501; - - public static final byte LI_TOUCH_EVENT_HOVER = 0x00; - public static final byte LI_TOUCH_EVENT_DOWN = 0x01; - public static final byte LI_TOUCH_EVENT_UP = 0x02; - public static final byte LI_TOUCH_EVENT_MOVE = 0x03; - public static final byte LI_TOUCH_EVENT_CANCEL = 0x04; - public static final byte LI_TOUCH_EVENT_BUTTON_ONLY = 0x05; - public static final byte LI_TOUCH_EVENT_HOVER_LEAVE = 0x06; - public static final byte LI_TOUCH_EVENT_CANCEL_ALL = 0x07; - - public static final byte LI_TOOL_TYPE_UNKNOWN = 0x00; - public static final byte LI_TOOL_TYPE_PEN = 0x01; - public static final byte LI_TOOL_TYPE_ERASER = 0x02; - - public static final byte LI_PEN_BUTTON_PRIMARY = 0x01; - public static final byte LI_PEN_BUTTON_SECONDARY = 0x02; - public static final byte LI_PEN_BUTTON_TERTIARY = 0x04; - - public static final byte LI_TILT_UNKNOWN = (byte)0xFF; - public static final short LI_ROT_UNKNOWN = (short)0xFFFF; - - public static final byte LI_CTYPE_UNKNOWN = 0x00; - public static final byte LI_CTYPE_XBOX = 0x01; - public static final byte LI_CTYPE_PS = 0x02; - public static final byte LI_CTYPE_NINTENDO = 0x03; - - public static final short LI_CCAP_ANALOG_TRIGGERS = 0x01; - public static final short LI_CCAP_RUMBLE = 0x02; - public static final short LI_CCAP_TRIGGER_RUMBLE = 0x04; - public static final short LI_CCAP_TOUCHPAD = 0x08; - public static final short LI_CCAP_ACCEL = 0x10; - public static final short LI_CCAP_GYRO = 0x20; - public static final short LI_CCAP_BATTERY_STATE = 0x40; - public static final short LI_CCAP_RGB_LED = 0x80; - - public static final byte LI_MOTION_TYPE_ACCEL = 0x01; - public static final byte LI_MOTION_TYPE_GYRO = 0x02; - - public static final byte LI_BATTERY_STATE_UNKNOWN = 0x00; - public static final byte LI_BATTERY_STATE_NOT_PRESENT = 0x01; - public static final byte LI_BATTERY_STATE_DISCHARGING = 0x02; - public static final byte LI_BATTERY_STATE_CHARGING = 0x03; - public static final byte LI_BATTERY_STATE_NOT_CHARGING = 0x04; // Connected to power but not charging - public static final byte LI_BATTERY_STATE_FULL = 0x05; - - public static final byte LI_BATTERY_PERCENTAGE_UNKNOWN = (byte)0xFF; - - private static AudioRenderer audioRenderer; - private static VideoDecoderRenderer videoRenderer; - private static NvConnectionListener connectionListener; - - static { - System.loadLibrary("moonlight-core"); - init(); - } - - public static int CAPABILITY_SLICES_PER_FRAME(byte slices) { - return slices << 24; - } - - public static class AudioConfiguration { - public final int channelCount; - public final int channelMask; - - public AudioConfiguration(int channelCount, int channelMask) { - this.channelCount = channelCount; - this.channelMask = channelMask; - } - - // Creates an AudioConfiguration from the integer value returned by moonlight-common-c - // See CHANNEL_COUNT_FROM_AUDIO_CONFIGURATION() and CHANNEL_MASK_FROM_AUDIO_CONFIGURATION() - // in Limelight.h - private AudioConfiguration(int audioConfiguration) { - // Check the magic byte before decoding to make sure we got something that's actually - // a MAKE_AUDIO_CONFIGURATION()-based value and not something else like an older version - // hardcoded AUDIO_CONFIGURATION value from an earlier version of moonlight-common-c. - if ((audioConfiguration & 0xFF) != 0xCA) { - throw new IllegalArgumentException("Audio configuration has invalid magic byte!"); - } - - this.channelCount = (audioConfiguration >> 8) & 0xFF; - this.channelMask = (audioConfiguration >> 16) & 0xFFFF; - } - - // See SURROUNDAUDIOINFO_FROM_AUDIO_CONFIGURATION() in Limelight.h - public int getSurroundAudioInfo() { - return channelMask << 16 | channelCount; - } - - @Override - public boolean equals(Object obj) { - if (obj instanceof AudioConfiguration) { - AudioConfiguration that = (AudioConfiguration)obj; - return this.toInt() == that.toInt(); - } - - return false; - } - - @Override - public int hashCode() { - return toInt(); - } - - // Returns the integer value expected by moonlight-common-c - // See MAKE_AUDIO_CONFIGURATION() in Limelight.h - public int toInt() { - return ((channelMask) << 16) | (channelCount << 8) | 0xCA; - } - } - - public static int bridgeDrSetup(int videoFormat, int width, int height, int redrawRate) { - if (videoRenderer != null) { - return videoRenderer.setup(videoFormat, width, height, redrawRate); - } - else { - return -1; - } - } - - public static void bridgeDrStart() { - if (videoRenderer != null) { - videoRenderer.start(); - } - } - - public static void bridgeDrStop() { - if (videoRenderer != null) { - videoRenderer.stop(); - } - } - - public static void bridgeDrCleanup() { - if (videoRenderer != null) { - videoRenderer.cleanup(); - } - } - - public static int bridgeDrSubmitDecodeUnit(byte[] decodeUnitData, int decodeUnitLength, int decodeUnitType, - int frameNumber, int frameType, char frameHostProcessingLatency, - long receiveTimeMs, long enqueueTimeMs) { - if (videoRenderer != null) { - return videoRenderer.submitDecodeUnit(decodeUnitData, decodeUnitLength, - decodeUnitType, frameNumber, frameType, frameHostProcessingLatency, receiveTimeMs, enqueueTimeMs); - } - else { - return DR_OK; - } - } - - public static int bridgeArInit(int audioConfiguration, int sampleRate, int samplesPerFrame) { - if (audioRenderer != null) { - return audioRenderer.setup(new AudioConfiguration(audioConfiguration), sampleRate, samplesPerFrame); - } - else { - return -1; - } - } - - public static void bridgeArStart() { - if (audioRenderer != null) { - audioRenderer.start(); - } - } - - public static void bridgeArStop() { - if (audioRenderer != null) { - audioRenderer.stop(); - } - } - - public static void bridgeArCleanup() { - if (audioRenderer != null) { - audioRenderer.cleanup(); - } - } - - public static void bridgeArPlaySample(short[] pcmData) { - if (audioRenderer != null) { - audioRenderer.playDecodedAudio(pcmData); - } - } - - public static void bridgeClStageStarting(int stage) { - if (connectionListener != null) { - connectionListener.stageStarting(getStageName(stage)); - } - } - - public static void bridgeClStageComplete(int stage) { - if (connectionListener != null) { - connectionListener.stageComplete(getStageName(stage)); - } - } - - public static void bridgeClStageFailed(int stage, int errorCode) { - if (connectionListener != null) { - connectionListener.stageFailed(getStageName(stage), getPortFlagsFromStage(stage), errorCode); - } - } - - public static void bridgeClConnectionStarted() { - if (connectionListener != null) { - connectionListener.connectionStarted(); - } - } - - public static void bridgeClConnectionTerminated(int errorCode) { - if (connectionListener != null) { - connectionListener.connectionTerminated(errorCode); - } - } - - public static void bridgeClRumble(short controllerNumber, short lowFreqMotor, short highFreqMotor) { - if (connectionListener != null) { - connectionListener.rumble(controllerNumber, lowFreqMotor, highFreqMotor); - } - } - - public static void bridgeClConnectionStatusUpdate(int connectionStatus) { - if (connectionListener != null) { - connectionListener.connectionStatusUpdate(connectionStatus); - } - } - - public static void bridgeClSetHdrMode(boolean enabled, byte[] hdrMetadata) { - if (connectionListener != null) { - connectionListener.setHdrMode(enabled, hdrMetadata); - } - } - - public static void bridgeClRumbleTriggers(short controllerNumber, short leftTrigger, short rightTrigger) { - if (connectionListener != null) { - connectionListener.rumbleTriggers(controllerNumber, leftTrigger, rightTrigger); - } - } - - public static void bridgeClSetMotionEventState(short controllerNumber, byte eventType, short sampleRateHz) { - if (connectionListener != null) { - connectionListener.setMotionEventState(controllerNumber, eventType, sampleRateHz); - } - } - - public static void bridgeClSetControllerLED(short controllerNumber, byte r, byte g, byte b) { - if (connectionListener != null) { - connectionListener.setControllerLED(controllerNumber, r, g, b); - } - } - - public static void setupBridge(VideoDecoderRenderer videoRenderer, AudioRenderer audioRenderer, NvConnectionListener connectionListener) { - MoonBridge.videoRenderer = videoRenderer; - MoonBridge.audioRenderer = audioRenderer; - MoonBridge.connectionListener = connectionListener; - } - - public static void cleanupBridge() { - MoonBridge.videoRenderer = null; - MoonBridge.audioRenderer = null; - MoonBridge.connectionListener = null; - } - - public static native int startConnection(String address, String appVersion, String gfeVersion, - String rtspSessionUrl, int serverCodecModeSupport, - int width, int height, int fps, - int bitrate, int packetSize, int streamingRemotely, - int audioConfiguration, int supportedVideoFormats, - int clientRefreshRateX100, - byte[] riAesKey, byte[] riAesIv, - int videoCapabilities, - int colorSpace, int colorRange); - - public static native void stopConnection(); - - public static native void interruptConnection(); - - public static native void sendMouseMove(short deltaX, short deltaY); - - public static native void sendMousePosition(short x, short y, short referenceWidth, short referenceHeight); - - public static native void sendMouseMoveAsMousePosition(short deltaX, short deltaY, short referenceWidth, short referenceHeight); - - public static native void sendMouseButton(byte buttonEvent, byte mouseButton); - - public static native void sendMultiControllerInput(short controllerNumber, - short activeGamepadMask, int buttonFlags, - byte leftTrigger, byte rightTrigger, - short leftStickX, short leftStickY, - short rightStickX, short rightStickY); - - public static native int sendTouchEvent(byte eventType, int pointerId, float x, float y, float pressure, - float contactAreaMajor, float contactAreaMinor, short rotation); - - public static native int sendPenEvent(byte eventType, byte toolType, byte penButtons, float x, float y, - float pressure, float contactAreaMajor, float contactAreaMinor, - short rotation, byte tilt); - - public static native int sendControllerArrivalEvent(byte controllerNumber, short activeGamepadMask, byte type, int supportedButtonFlags, short capabilities); - - public static native int sendControllerTouchEvent(byte controllerNumber, byte eventType, int pointerId, float x, float y, float pressure); - - public static native int sendControllerMotionEvent(byte controllerNumber, byte motionType, float x, float y, float z); - - public static native int sendControllerBatteryEvent(byte controllerNumber, byte batteryState, byte batteryPercentage); - - public static native void sendKeyboardInput(short keyMap, byte keyDirection, byte modifier, byte flags); - - public static native void sendMouseHighResScroll(short scrollAmount); - - public static native void sendMouseHighResHScroll(short scrollAmount); - - public static native void sendUtf8Text(String text); - - public static native String getStageName(int stage); - - public static native String findExternalAddressIP4(String stunHostName, int stunPort); - - public static native int getPendingAudioDuration(); - - public static native int getPendingVideoFrames(); - - public static native int testClientConnectivity(String testServerHostName, int referencePort, int testFlags); - - public static native int getPortFlagsFromStage(int stage); - - public static native int getPortFlagsFromTerminationErrorCode(int errorCode); - - public static native String stringifyPortFlags(int portFlags, String separator); - - // The RTT is in the top 32 bits, and the RTT variance is in the bottom 32 bits - public static native long getEstimatedRttInfo(); - - public static native String getLaunchUrlQueryParameters(); - - public static native byte guessControllerType(int vendorId, int productId); - - public static native boolean guessControllerHasPaddles(int vendorId, int productId); - - public static native boolean guessControllerHasShareButton(int vendorId, int productId); - - public static native void init(); -} +package com.limelight.nvstream.jni; + +import com.limelight.nvstream.NvConnectionListener; +import com.limelight.nvstream.av.audio.AudioRenderer; +import com.limelight.nvstream.av.video.VideoDecoderRenderer; + +public class MoonBridge { + /* See documentation in Limelight.h for information about these functions and constants */ + + public static final AudioConfiguration AUDIO_CONFIGURATION_STEREO = new AudioConfiguration(2, 0x3); + public static final AudioConfiguration AUDIO_CONFIGURATION_51_SURROUND = new AudioConfiguration(6, 0x3F); + public static final AudioConfiguration AUDIO_CONFIGURATION_71_SURROUND = new AudioConfiguration(8, 0x63F); + + public static final int VIDEO_FORMAT_H264 = 0x0001; + public static final int VIDEO_FORMAT_H265 = 0x0100; + public static final int VIDEO_FORMAT_H265_MAIN10 = 0x0200; + public static final int VIDEO_FORMAT_AV1_MAIN8 = 0x1000; + public static final int VIDEO_FORMAT_AV1_MAIN10 = 0x2000; + + public static final int VIDEO_FORMAT_MASK_H264 = 0x000F; + public static final int VIDEO_FORMAT_MASK_H265 = 0x0F00; + public static final int VIDEO_FORMAT_MASK_AV1 = 0xF000; + public static final int VIDEO_FORMAT_MASK_10BIT = 0x2200; + + public static final int BUFFER_TYPE_PICDATA = 0; + public static final int BUFFER_TYPE_SPS = 1; + public static final int BUFFER_TYPE_PPS = 2; + public static final int BUFFER_TYPE_VPS = 3; + + public static final int FRAME_TYPE_PFRAME = 0; + public static final int FRAME_TYPE_IDR = 1; + + public static final int COLORSPACE_REC_601 = 0; + public static final int COLORSPACE_REC_709 = 1; + public static final int COLORSPACE_REC_2020 = 2; + + public static final int COLOR_RANGE_LIMITED = 0; + public static final int COLOR_RANGE_FULL = 1; + + public static final int CAPABILITY_DIRECT_SUBMIT = 1; + public static final int CAPABILITY_REFERENCE_FRAME_INVALIDATION_AVC = 2; + public static final int CAPABILITY_REFERENCE_FRAME_INVALIDATION_HEVC = 4; + public static final int CAPABILITY_REFERENCE_FRAME_INVALIDATION_AV1 = 0x40; + + public static final int DR_OK = 0; + public static final int DR_NEED_IDR = -1; + + public static final int CONN_STATUS_OKAY = 0; + public static final int CONN_STATUS_POOR = 1; + + public static final int ML_ERROR_GRACEFUL_TERMINATION = 0; + public static final int ML_ERROR_NO_VIDEO_TRAFFIC = -100; + public static final int ML_ERROR_NO_VIDEO_FRAME = -101; + public static final int ML_ERROR_UNEXPECTED_EARLY_TERMINATION = -102; + public static final int ML_ERROR_PROTECTED_CONTENT = -103; + public static final int ML_ERROR_FRAME_CONVERSION = -104; + + public static final int ML_PORT_INDEX_TCP_47984 = 0; + public static final int ML_PORT_INDEX_TCP_47989 = 1; + public static final int ML_PORT_INDEX_TCP_48010 = 2; + public static final int ML_PORT_INDEX_UDP_47998 = 8; + public static final int ML_PORT_INDEX_UDP_47999 = 9; + public static final int ML_PORT_INDEX_UDP_48000 = 10; + public static final int ML_PORT_INDEX_UDP_48010 = 11; + + public static final int ML_PORT_FLAG_ALL = 0xFFFFFFFF; + public static final int ML_PORT_FLAG_TCP_47984 = 0x0001; + public static final int ML_PORT_FLAG_TCP_47989 = 0x0002; + public static final int ML_PORT_FLAG_TCP_48010 = 0x0004; + public static final int ML_PORT_FLAG_UDP_47998 = 0x0100; + public static final int ML_PORT_FLAG_UDP_47999 = 0x0200; + public static final int ML_PORT_FLAG_UDP_48000 = 0x0400; + public static final int ML_PORT_FLAG_UDP_48010 = 0x0800; + + public static final int ML_TEST_RESULT_INCONCLUSIVE = 0xFFFFFFFF; + + public static final byte SS_KBE_FLAG_NON_NORMALIZED = 0x01; + + public static final int LI_ERR_UNSUPPORTED = -5501; + + public static final byte LI_TOUCH_EVENT_HOVER = 0x00; + public static final byte LI_TOUCH_EVENT_DOWN = 0x01; + public static final byte LI_TOUCH_EVENT_UP = 0x02; + public static final byte LI_TOUCH_EVENT_MOVE = 0x03; + public static final byte LI_TOUCH_EVENT_CANCEL = 0x04; + public static final byte LI_TOUCH_EVENT_BUTTON_ONLY = 0x05; + public static final byte LI_TOUCH_EVENT_HOVER_LEAVE = 0x06; + public static final byte LI_TOUCH_EVENT_CANCEL_ALL = 0x07; + + public static final byte LI_TOOL_TYPE_UNKNOWN = 0x00; + public static final byte LI_TOOL_TYPE_PEN = 0x01; + public static final byte LI_TOOL_TYPE_ERASER = 0x02; + + public static final byte LI_PEN_BUTTON_PRIMARY = 0x01; + public static final byte LI_PEN_BUTTON_SECONDARY = 0x02; + public static final byte LI_PEN_BUTTON_TERTIARY = 0x04; + + public static final byte LI_TILT_UNKNOWN = (byte)0xFF; + public static final short LI_ROT_UNKNOWN = (short)0xFFFF; + + public static final byte LI_CTYPE_UNKNOWN = 0x00; + public static final byte LI_CTYPE_XBOX = 0x01; + public static final byte LI_CTYPE_PS = 0x02; + public static final byte LI_CTYPE_NINTENDO = 0x03; + + public static final short LI_CCAP_ANALOG_TRIGGERS = 0x01; + public static final short LI_CCAP_RUMBLE = 0x02; + public static final short LI_CCAP_TRIGGER_RUMBLE = 0x04; + public static final short LI_CCAP_TOUCHPAD = 0x08; + public static final short LI_CCAP_ACCEL = 0x10; + public static final short LI_CCAP_GYRO = 0x20; + public static final short LI_CCAP_BATTERY_STATE = 0x40; + public static final short LI_CCAP_RGB_LED = 0x80; + + public static final byte LI_MOTION_TYPE_ACCEL = 0x01; + public static final byte LI_MOTION_TYPE_GYRO = 0x02; + + public static final byte LI_BATTERY_STATE_UNKNOWN = 0x00; + public static final byte LI_BATTERY_STATE_NOT_PRESENT = 0x01; + public static final byte LI_BATTERY_STATE_DISCHARGING = 0x02; + public static final byte LI_BATTERY_STATE_CHARGING = 0x03; + public static final byte LI_BATTERY_STATE_NOT_CHARGING = 0x04; // Connected to power but not charging + public static final byte LI_BATTERY_STATE_FULL = 0x05; + + public static final byte LI_BATTERY_PERCENTAGE_UNKNOWN = (byte)0xFF; + + private static AudioRenderer audioRenderer; + private static VideoDecoderRenderer videoRenderer; + private static NvConnectionListener connectionListener; + + static { + System.loadLibrary("moonlight-core"); + init(); + } + + public static int CAPABILITY_SLICES_PER_FRAME(byte slices) { + return slices << 24; + } + + public static class AudioConfiguration { + public final int channelCount; + public final int channelMask; + + public AudioConfiguration(int channelCount, int channelMask) { + this.channelCount = channelCount; + this.channelMask = channelMask; + } + + // Creates an AudioConfiguration from the integer value returned by moonlight-common-c + // See CHANNEL_COUNT_FROM_AUDIO_CONFIGURATION() and CHANNEL_MASK_FROM_AUDIO_CONFIGURATION() + // in Limelight.h + private AudioConfiguration(int audioConfiguration) { + // Check the magic byte before decoding to make sure we got something that's actually + // a MAKE_AUDIO_CONFIGURATION()-based value and not something else like an older version + // hardcoded AUDIO_CONFIGURATION value from an earlier version of moonlight-common-c. + if ((audioConfiguration & 0xFF) != 0xCA) { + throw new IllegalArgumentException("Audio configuration has invalid magic byte!"); + } + + this.channelCount = (audioConfiguration >> 8) & 0xFF; + this.channelMask = (audioConfiguration >> 16) & 0xFFFF; + } + + // See SURROUNDAUDIOINFO_FROM_AUDIO_CONFIGURATION() in Limelight.h + public int getSurroundAudioInfo() { + return channelMask << 16 | channelCount; + } + + @Override + public boolean equals(Object obj) { + if (obj instanceof AudioConfiguration) { + AudioConfiguration that = (AudioConfiguration)obj; + return this.toInt() == that.toInt(); + } + + return false; + } + + @Override + public int hashCode() { + return toInt(); + } + + // Returns the integer value expected by moonlight-common-c + // See MAKE_AUDIO_CONFIGURATION() in Limelight.h + public int toInt() { + return ((channelMask) << 16) | (channelCount << 8) | 0xCA; + } + } + + public static int bridgeDrSetup(int videoFormat, int width, int height, int redrawRate) { + if (videoRenderer != null) { + return videoRenderer.setup(videoFormat, width, height, redrawRate); + } + else { + return -1; + } + } + + public static void bridgeDrStart() { + if (videoRenderer != null) { + videoRenderer.start(); + } + } + + public static void bridgeDrStop() { + if (videoRenderer != null) { + videoRenderer.stop(); + } + } + + public static void bridgeDrCleanup() { + if (videoRenderer != null) { + videoRenderer.cleanup(); + } + } + + //todo 不显示画面 + public static int bridgeDrSubmitDecodeUnit(byte[] decodeUnitData, int decodeUnitLength, int decodeUnitType, + int frameNumber, int frameType, char frameHostProcessingLatency, + long receiveTimeMs, long enqueueTimeMs) { + if (videoRenderer != null) { + return videoRenderer.submitDecodeUnit(decodeUnitData, decodeUnitLength, + decodeUnitType, frameNumber, frameType, frameHostProcessingLatency, receiveTimeMs, enqueueTimeMs); + } + else { + return DR_OK; + } + } + + public static int bridgeArInit(int audioConfiguration, int sampleRate, int samplesPerFrame) { + if (audioRenderer != null) { + return audioRenderer.setup(new AudioConfiguration(audioConfiguration), sampleRate, samplesPerFrame); + } + else { + return -1; + } + } + + public static void bridgeArStart() { + if (audioRenderer != null) { + audioRenderer.start(); + } + } + + public static void bridgeArStop() { + if (audioRenderer != null) { + audioRenderer.stop(); + } + } + + public static void bridgeArCleanup() { + if (audioRenderer != null) { + audioRenderer.cleanup(); + } + } + + //静音 todo + public static void bridgeArPlaySample(short[] pcmData) { + if (audioRenderer != null) { + audioRenderer.playDecodedAudio(pcmData); + } + } + + public static void bridgeClStageStarting(int stage) { + if (connectionListener != null) { + connectionListener.stageStarting(getStageName(stage)); + } + } + + public static void bridgeClStageComplete(int stage) { + if (connectionListener != null) { + connectionListener.stageComplete(getStageName(stage)); + } + } + + public static void bridgeClStageFailed(int stage, int errorCode) { + if (connectionListener != null) { + connectionListener.stageFailed(getStageName(stage), getPortFlagsFromStage(stage), errorCode); + } + } + + public static void bridgeClConnectionStarted() { + if (connectionListener != null) { + connectionListener.connectionStarted(); + } + } + + public static void bridgeClConnectionTerminated(int errorCode) { + if (connectionListener != null) { + connectionListener.connectionTerminated(errorCode); + } + } + + public static void bridgeClRumble(short controllerNumber, short lowFreqMotor, short highFreqMotor) { + if (connectionListener != null) { + connectionListener.rumble(controllerNumber, lowFreqMotor, highFreqMotor); + } + } + + public static void bridgeClConnectionStatusUpdate(int connectionStatus) { + if (connectionListener != null) { + connectionListener.connectionStatusUpdate(connectionStatus); + } + } + + public static void bridgeClSetHdrMode(boolean enabled, byte[] hdrMetadata) { + if (connectionListener != null) { + connectionListener.setHdrMode(enabled, hdrMetadata); + } + } + + public static void bridgeClRumbleTriggers(short controllerNumber, short leftTrigger, short rightTrigger) { + if (connectionListener != null) { + connectionListener.rumbleTriggers(controllerNumber, leftTrigger, rightTrigger); + } + } + + public static void bridgeClSetMotionEventState(short controllerNumber, byte eventType, short sampleRateHz) { + if (connectionListener != null) { + connectionListener.setMotionEventState(controllerNumber, eventType, sampleRateHz); + } + } + + public static void bridgeClSetControllerLED(short controllerNumber, byte r, byte g, byte b) { + if (connectionListener != null) { + connectionListener.setControllerLED(controllerNumber, r, g, b); + } + } + + public static void setupBridge(VideoDecoderRenderer videoRenderer, AudioRenderer audioRenderer, NvConnectionListener connectionListener) { + MoonBridge.videoRenderer = videoRenderer; + MoonBridge.audioRenderer = audioRenderer; + MoonBridge.connectionListener = connectionListener; + } + + public static void cleanupBridge() { + MoonBridge.videoRenderer = null; + MoonBridge.audioRenderer = null; + MoonBridge.connectionListener = null; + } + + public static native int startConnection(String address, String appVersion, String gfeVersion, + String rtspSessionUrl, int serverCodecModeSupport, + int width, int height, int fps, + int bitrate, int packetSize, int streamingRemotely, + int audioConfiguration, int supportedVideoFormats, + int clientRefreshRateX100, + byte[] riAesKey, byte[] riAesIv, + int videoCapabilities, + int colorSpace, int colorRange); + + public static native void stopConnection(); + + public static native void interruptConnection(); + + public static native void sendMouseMove(short deltaX, short deltaY); + + public static native void sendMousePosition(short x, short y, short referenceWidth, short referenceHeight); + + public static native void sendMouseMoveAsMousePosition(short deltaX, short deltaY, short referenceWidth, short referenceHeight); + + public static native void sendMouseButton(byte buttonEvent, byte mouseButton); + + public static native void sendMultiControllerInput(short controllerNumber, + short activeGamepadMask, int buttonFlags, + byte leftTrigger, byte rightTrigger, + short leftStickX, short leftStickY, + short rightStickX, short rightStickY); + + public static native int sendTouchEvent(byte eventType, int pointerId, float x, float y, float pressure, + float contactAreaMajor, float contactAreaMinor, short rotation); + + public static native int sendPenEvent(byte eventType, byte toolType, byte penButtons, float x, float y, + float pressure, float contactAreaMajor, float contactAreaMinor, + short rotation, byte tilt); + + public static native int sendControllerArrivalEvent(byte controllerNumber, short activeGamepadMask, byte type, int supportedButtonFlags, short capabilities); + + public static native int sendControllerTouchEvent(byte controllerNumber, byte eventType, int pointerId, float x, float y, float pressure); + + public static native int sendControllerMotionEvent(byte controllerNumber, byte motionType, float x, float y, float z); + + public static native int sendControllerBatteryEvent(byte controllerNumber, byte batteryState, byte batteryPercentage); + + public static native void sendKeyboardInput(short keyMap, byte keyDirection, byte modifier, byte flags); + + public static native void sendMouseHighResScroll(short scrollAmount); + + public static native void sendMouseHighResHScroll(short scrollAmount); + + public static native void sendUtf8Text(String text); + + public static native String getStageName(int stage); + + public static native String findExternalAddressIP4(String stunHostName, int stunPort); + + public static native int getPendingAudioDuration(); + + public static native int getPendingVideoFrames(); + + public static native int testClientConnectivity(String testServerHostName, int referencePort, int testFlags); + + public static native int getPortFlagsFromStage(int stage); + + public static native int getPortFlagsFromTerminationErrorCode(int errorCode); + + public static native String stringifyPortFlags(int portFlags, String separator); + + // The RTT is in the top 32 bits, and the RTT variance is in the bottom 32 bits + public static native long getEstimatedRttInfo(); + + public static native String getLaunchUrlQueryParameters(); + + public static native byte guessControllerType(int vendorId, int productId); + + public static native boolean guessControllerHasPaddles(int vendorId, int productId); + + public static native boolean guessControllerHasShareButton(int vendorId, int productId); + + public static native void init(); +} diff --git a/app/src/main/java/com/limelight/nvstream/mdns/JmDNSDiscoveryAgent.java b/app/src/main/java/com/limelight/nvstream/mdns/JmDNSDiscoveryAgent.java old mode 100644 new mode 100755 index 466304cf1f..ee3c612b81 --- a/app/src/main/java/com/limelight/nvstream/mdns/JmDNSDiscoveryAgent.java +++ b/app/src/main/java/com/limelight/nvstream/mdns/JmDNSDiscoveryAgent.java @@ -1,269 +1,269 @@ -package com.limelight.nvstream.mdns; - -import android.content.Context; -import android.net.wifi.WifiManager; - -import java.io.IOException; -import java.io.UnsupportedEncodingException; -import java.net.InetAddress; -import java.net.NetworkInterface; -import java.util.ArrayList; -import java.util.HashSet; - -import javax.jmdns.JmmDNS; -import javax.jmdns.NetworkTopologyDiscovery; -import javax.jmdns.ServiceEvent; -import javax.jmdns.ServiceInfo; -import javax.jmdns.ServiceListener; -import javax.jmdns.impl.NetworkTopologyDiscoveryImpl; - -import com.limelight.LimeLog; - -public class JmDNSDiscoveryAgent extends MdnsDiscoveryAgent implements ServiceListener { - private static final String SERVICE_TYPE = "_nvstream._tcp.local."; - private WifiManager.MulticastLock multicastLock; - private Thread discoveryThread; - private HashSet pendingResolution = new HashSet<>(); - - // The resolver factory's instance member has a static lifetime which - // means our ref count and listener must be static also. - private static int resolverRefCount = 0; - private static HashSet listeners = new HashSet<>(); - private static ServiceListener nvstreamListener = new ServiceListener() { - @Override - public void serviceAdded(ServiceEvent event) { - HashSet localListeners; - - // Copy the listener set into a new set so we can invoke - // the callbacks without holding the listeners monitor the - // whole time. - synchronized (listeners) { - localListeners = new HashSet(listeners); - } - - for (ServiceListener listener : localListeners) { - listener.serviceAdded(event); - } - } - - @Override - public void serviceRemoved(ServiceEvent event) { - HashSet localListeners; - - // Copy the listener set into a new set so we can invoke - // the callbacks without holding the listeners monitor the - // whole time. - synchronized (listeners) { - localListeners = new HashSet(listeners); - } - - for (ServiceListener listener : localListeners) { - listener.serviceRemoved(event); - } - } - - @Override - public void serviceResolved(ServiceEvent event) { - HashSet localListeners; - - // Copy the listener set into a new set so we can invoke - // the callbacks without holding the listeners monitor the - // whole time. - synchronized (listeners) { - localListeners = new HashSet(listeners); - } - - for (ServiceListener listener : localListeners) { - listener.serviceResolved(event); - } - } - }; - - public static class MyNetworkTopologyDiscovery extends NetworkTopologyDiscoveryImpl { - @Override - public boolean useInetAddress(NetworkInterface networkInterface, InetAddress interfaceAddress) { - // This is an copy of jmDNS's implementation, except we omit the multicast check, since - // it seems at least some devices lie about interfaces not supporting multicast when they really do. - try { - if (!networkInterface.isUp()) { - return false; - } - - /* - if (!networkInterface.supportsMulticast()) { - return false; - } - */ - - if (networkInterface.isLoopback()) { - return false; - } - - return true; - } catch (Exception exception) { - return false; - } - } - } - - static { - // Override jmDNS's default topology discovery class with ours - NetworkTopologyDiscovery.Factory.setClassDelegate(new NetworkTopologyDiscovery.Factory.ClassDelegate() { - @Override - public NetworkTopologyDiscovery newNetworkTopologyDiscovery() { - return new MyNetworkTopologyDiscovery(); - } - }); - } - - private static JmmDNS referenceResolver() { - synchronized (JmDNSDiscoveryAgent.class) { - JmmDNS instance = JmmDNS.Factory.getInstance(); - if (++resolverRefCount == 1) { - // This will cause the listener to be invoked for known hosts immediately. - // JmDNS only supports one listener per service, so we have to do this here - // with a static listener. - instance.addServiceListener(SERVICE_TYPE, nvstreamListener); - } - return instance; - } - } - - private static void dereferenceResolver() { - synchronized (JmDNSDiscoveryAgent.class) { - if (--resolverRefCount == 0) { - try { - JmmDNS.Factory.close(); - } catch (IOException e) {} - } - } - } - - public JmDNSDiscoveryAgent(Context context, MdnsDiscoveryListener listener) { - super(listener); - - // Create the multicast lock required to receive mDNS traffic - WifiManager wifiMgr = (WifiManager) context.getSystemService(Context.WIFI_SERVICE); - multicastLock = wifiMgr.createMulticastLock("Limelight mDNS"); - multicastLock.setReferenceCounted(false); - } - - private void handleResolvedServiceInfo(ServiceInfo info) { - synchronized (pendingResolution) { - pendingResolution.remove(info.getName()); - } - - try { - handleServiceInfo(info); - } catch (UnsupportedEncodingException e) { - // Invalid DNS response - LimeLog.info("mDNS: Invalid response for machine: "+info.getName()); - return; - } - } - - private void handleServiceInfo(ServiceInfo info) throws UnsupportedEncodingException { - reportNewComputer(info.getName(), info.getPort(), info.getInet4Addresses(), info.getInet6Addresses()); - } - - public void startDiscovery(final int discoveryIntervalMs) { - // Kill any existing discovery before starting a new one - stopDiscovery(); - - // Acquire the multicast lock to start receiving mDNS traffic - multicastLock.acquire(); - - // Add our listener to the set - synchronized (listeners) { - listeners.add(JmDNSDiscoveryAgent.this); - } - - discoveryThread = new Thread() { - @Override - public void run() { - // This may result in listener callbacks so we must register - // our listener first. - JmmDNS resolver = referenceResolver(); - - try { - while (!Thread.interrupted()) { - // Start an mDNS request - resolver.requestServiceInfo(SERVICE_TYPE, null, discoveryIntervalMs); - - // Run service resolution again for pending machines - ArrayList pendingNames; - synchronized (pendingResolution) { - pendingNames = new ArrayList(pendingResolution); - } - for (String name : pendingNames) { - LimeLog.info("mDNS: Retrying service resolution for machine: "+name); - ServiceInfo[] infos = resolver.getServiceInfos(SERVICE_TYPE, name, 500); - if (infos != null && infos.length != 0) { - LimeLog.info("mDNS: Resolved (retry) with "+infos.length+" service entries"); - for (ServiceInfo svcinfo : infos) { - handleResolvedServiceInfo(svcinfo); - } - } - } - - // Wait for the next polling interval - try { - Thread.sleep(discoveryIntervalMs); - } catch (InterruptedException e) { - break; - } - } - } - finally { - // Dereference the resolver - dereferenceResolver(); - } - } - }; - discoveryThread.setName("mDNS Discovery Thread"); - discoveryThread.start(); - } - - public void stopDiscovery() { - // Release the multicast lock to stop receiving mDNS traffic - multicastLock.release(); - - // Remove our listener from the set - synchronized (listeners) { - listeners.remove(JmDNSDiscoveryAgent.this); - } - - // If there's already a running thread, interrupt it - if (discoveryThread != null) { - discoveryThread.interrupt(); - discoveryThread = null; - } - } - - @Override - public void serviceAdded(ServiceEvent event) { - LimeLog.info("mDNS: Machine appeared: "+event.getInfo().getName()); - - ServiceInfo info = event.getDNS().getServiceInfo(SERVICE_TYPE, event.getInfo().getName(), 500); - if (info == null) { - // This machine is pending resolution - synchronized (pendingResolution) { - pendingResolution.add(event.getInfo().getName()); - } - return; - } - - LimeLog.info("mDNS: Resolved (blocking)"); - handleResolvedServiceInfo(info); - } - - @Override - public void serviceRemoved(ServiceEvent event) { - LimeLog.info("mDNS: Machine disappeared: "+event.getInfo().getName()); - } - - @Override - public void serviceResolved(ServiceEvent event) { - // We handle this synchronously - } -} +package com.limelight.nvstream.mdns; + +import android.content.Context; +import android.net.wifi.WifiManager; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.InetAddress; +import java.net.NetworkInterface; +import java.util.ArrayList; +import java.util.HashSet; + +import javax.jmdns.JmmDNS; +import javax.jmdns.NetworkTopologyDiscovery; +import javax.jmdns.ServiceEvent; +import javax.jmdns.ServiceInfo; +import javax.jmdns.ServiceListener; +import javax.jmdns.impl.NetworkTopologyDiscoveryImpl; + +import com.limelight.LimeLog; + +public class JmDNSDiscoveryAgent extends MdnsDiscoveryAgent implements ServiceListener { + private static final String SERVICE_TYPE = "_nvstream._tcp.local."; + private WifiManager.MulticastLock multicastLock; + private Thread discoveryThread; + private HashSet pendingResolution = new HashSet<>(); + + // The resolver factory's instance member has a static lifetime which + // means our ref count and listener must be static also. + private static int resolverRefCount = 0; + private static HashSet listeners = new HashSet<>(); + private static ServiceListener nvstreamListener = new ServiceListener() { + @Override + public void serviceAdded(ServiceEvent event) { + HashSet localListeners; + + // Copy the listener set into a new set so we can invoke + // the callbacks without holding the listeners monitor the + // whole time. + synchronized (listeners) { + localListeners = new HashSet(listeners); + } + + for (ServiceListener listener : localListeners) { + listener.serviceAdded(event); + } + } + + @Override + public void serviceRemoved(ServiceEvent event) { + HashSet localListeners; + + // Copy the listener set into a new set so we can invoke + // the callbacks without holding the listeners monitor the + // whole time. + synchronized (listeners) { + localListeners = new HashSet(listeners); + } + + for (ServiceListener listener : localListeners) { + listener.serviceRemoved(event); + } + } + + @Override + public void serviceResolved(ServiceEvent event) { + HashSet localListeners; + + // Copy the listener set into a new set so we can invoke + // the callbacks without holding the listeners monitor the + // whole time. + synchronized (listeners) { + localListeners = new HashSet(listeners); + } + + for (ServiceListener listener : localListeners) { + listener.serviceResolved(event); + } + } + }; + + public static class MyNetworkTopologyDiscovery extends NetworkTopologyDiscoveryImpl { + @Override + public boolean useInetAddress(NetworkInterface networkInterface, InetAddress interfaceAddress) { + // This is an copy of jmDNS's implementation, except we omit the multicast check, since + // it seems at least some devices lie about interfaces not supporting multicast when they really do. + try { + if (!networkInterface.isUp()) { + return false; + } + + /* + if (!networkInterface.supportsMulticast()) { + return false; + } + */ + + if (networkInterface.isLoopback()) { + return false; + } + + return true; + } catch (Exception exception) { + return false; + } + } + } + + static { + // Override jmDNS's default topology discovery class with ours + NetworkTopologyDiscovery.Factory.setClassDelegate(new NetworkTopologyDiscovery.Factory.ClassDelegate() { + @Override + public NetworkTopologyDiscovery newNetworkTopologyDiscovery() { + return new MyNetworkTopologyDiscovery(); + } + }); + } + + private static JmmDNS referenceResolver() { + synchronized (JmDNSDiscoveryAgent.class) { + JmmDNS instance = JmmDNS.Factory.getInstance(); + if (++resolverRefCount == 1) { + // This will cause the listener to be invoked for known hosts immediately. + // JmDNS only supports one listener per service, so we have to do this here + // with a static listener. + instance.addServiceListener(SERVICE_TYPE, nvstreamListener); + } + return instance; + } + } + + private static void dereferenceResolver() { + synchronized (JmDNSDiscoveryAgent.class) { + if (--resolverRefCount == 0) { + try { + JmmDNS.Factory.close(); + } catch (IOException e) {} + } + } + } + + public JmDNSDiscoveryAgent(Context context, MdnsDiscoveryListener listener) { + super(listener); + + // Create the multicast lock required to receive mDNS traffic + WifiManager wifiMgr = (WifiManager) context.getSystemService(Context.WIFI_SERVICE); + multicastLock = wifiMgr.createMulticastLock("Limelight mDNS"); + multicastLock.setReferenceCounted(false); + } + + private void handleResolvedServiceInfo(ServiceInfo info) { + synchronized (pendingResolution) { + pendingResolution.remove(info.getName()); + } + + try { + handleServiceInfo(info); + } catch (UnsupportedEncodingException e) { + // Invalid DNS response + LimeLog.info("mDNS: Invalid response for machine: "+info.getName()); + return; + } + } + + private void handleServiceInfo(ServiceInfo info) throws UnsupportedEncodingException { + reportNewComputer(info.getName(), info.getPort(), info.getInet4Addresses(), info.getInet6Addresses()); + } + + public void startDiscovery(final int discoveryIntervalMs) { + // Kill any existing discovery before starting a new one + stopDiscovery(); + + // Acquire the multicast lock to start receiving mDNS traffic + multicastLock.acquire(); + + // Add our listener to the set + synchronized (listeners) { + listeners.add(JmDNSDiscoveryAgent.this); + } + + discoveryThread = new Thread() { + @Override + public void run() { + // This may result in listener callbacks so we must register + // our listener first. + JmmDNS resolver = referenceResolver(); + + try { + while (!Thread.interrupted()) { + // Start an mDNS request + resolver.requestServiceInfo(SERVICE_TYPE, null, discoveryIntervalMs); + + // Run service resolution again for pending machines + ArrayList pendingNames; + synchronized (pendingResolution) { + pendingNames = new ArrayList(pendingResolution); + } + for (String name : pendingNames) { + LimeLog.info("mDNS: Retrying service resolution for machine: "+name); + ServiceInfo[] infos = resolver.getServiceInfos(SERVICE_TYPE, name, 500); + if (infos != null && infos.length != 0) { + LimeLog.info("mDNS: Resolved (retry) with "+infos.length+" service entries"); + for (ServiceInfo svcinfo : infos) { + handleResolvedServiceInfo(svcinfo); + } + } + } + + // Wait for the next polling interval + try { + Thread.sleep(discoveryIntervalMs); + } catch (InterruptedException e) { + break; + } + } + } + finally { + // Dereference the resolver + dereferenceResolver(); + } + } + }; + discoveryThread.setName("mDNS Discovery Thread"); + discoveryThread.start(); + } + + public void stopDiscovery() { + // Release the multicast lock to stop receiving mDNS traffic + multicastLock.release(); + + // Remove our listener from the set + synchronized (listeners) { + listeners.remove(JmDNSDiscoveryAgent.this); + } + + // If there's already a running thread, interrupt it + if (discoveryThread != null) { + discoveryThread.interrupt(); + discoveryThread = null; + } + } + + @Override + public void serviceAdded(ServiceEvent event) { + LimeLog.info("mDNS: Machine appeared: "+event.getInfo().getName()); + + ServiceInfo info = event.getDNS().getServiceInfo(SERVICE_TYPE, event.getInfo().getName(), 500); + if (info == null) { + // This machine is pending resolution + synchronized (pendingResolution) { + pendingResolution.add(event.getInfo().getName()); + } + return; + } + + LimeLog.info("mDNS: Resolved (blocking)"); + handleResolvedServiceInfo(info); + } + + @Override + public void serviceRemoved(ServiceEvent event) { + LimeLog.info("mDNS: Machine disappeared: "+event.getInfo().getName()); + } + + @Override + public void serviceResolved(ServiceEvent event) { + // We handle this synchronously + } +} diff --git a/app/src/main/java/com/limelight/nvstream/mdns/MdnsComputer.java b/app/src/main/java/com/limelight/nvstream/mdns/MdnsComputer.java old mode 100644 new mode 100755 index bdd7a17332..67f00b420a --- a/app/src/main/java/com/limelight/nvstream/mdns/MdnsComputer.java +++ b/app/src/main/java/com/limelight/nvstream/mdns/MdnsComputer.java @@ -1,71 +1,71 @@ -package com.limelight.nvstream.mdns; - -import java.net.Inet6Address; -import java.net.InetAddress; - -public class MdnsComputer { - private InetAddress localAddr; - private Inet6Address v6Addr; - private int port; - private String name; - - public MdnsComputer(String name, InetAddress localAddress, Inet6Address v6Addr, int port) { - this.name = name; - this.localAddr = localAddress; - this.v6Addr = v6Addr; - this.port = port; - } - - public String getName() { - return name; - } - - public InetAddress getLocalAddress() { - return localAddr; - } - - public Inet6Address getIpv6Address() { - return v6Addr; - } - - public int getPort() { - return port; - } - - @Override - public int hashCode() { - return name.hashCode(); - } - - @Override - public boolean equals(Object o) { - if (o instanceof MdnsComputer) { - MdnsComputer other = (MdnsComputer)o; - - if (!other.name.equals(name) || other.port != port) { - return false; - } - - if ((other.localAddr != null && localAddr == null) || - (other.localAddr == null && localAddr != null) || - (other.localAddr != null && !other.localAddr.equals(localAddr))) { - return false; - } - - if ((other.v6Addr != null && v6Addr == null) || - (other.v6Addr == null && v6Addr != null) || - (other.v6Addr != null && !other.v6Addr.equals(v6Addr))) { - return false; - } - - return true; - } - - return false; - } - - @Override - public String toString() { - return "["+name+" - "+localAddr+" - "+v6Addr+"]"; - } -} +package com.limelight.nvstream.mdns; + +import java.net.Inet6Address; +import java.net.InetAddress; + +public class MdnsComputer { + private InetAddress localAddr; + private Inet6Address v6Addr; + private int port; + private String name; + + public MdnsComputer(String name, InetAddress localAddress, Inet6Address v6Addr, int port) { + this.name = name; + this.localAddr = localAddress; + this.v6Addr = v6Addr; + this.port = port; + } + + public String getName() { + return name; + } + + public InetAddress getLocalAddress() { + return localAddr; + } + + public Inet6Address getIpv6Address() { + return v6Addr; + } + + public int getPort() { + return port; + } + + @Override + public int hashCode() { + return name.hashCode(); + } + + @Override + public boolean equals(Object o) { + if (o instanceof MdnsComputer) { + MdnsComputer other = (MdnsComputer)o; + + if (!other.name.equals(name) || other.port != port) { + return false; + } + + if ((other.localAddr != null && localAddr == null) || + (other.localAddr == null && localAddr != null) || + (other.localAddr != null && !other.localAddr.equals(localAddr))) { + return false; + } + + if ((other.v6Addr != null && v6Addr == null) || + (other.v6Addr == null && v6Addr != null) || + (other.v6Addr != null && !other.v6Addr.equals(v6Addr))) { + return false; + } + + return true; + } + + return false; + } + + @Override + public String toString() { + return "["+name+" - "+localAddr+" - "+v6Addr+"]"; + } +} diff --git a/app/src/main/java/com/limelight/nvstream/mdns/MdnsDiscoveryAgent.java b/app/src/main/java/com/limelight/nvstream/mdns/MdnsDiscoveryAgent.java old mode 100644 new mode 100755 index 661578b6ff..825c9a7fd6 --- a/app/src/main/java/com/limelight/nvstream/mdns/MdnsDiscoveryAgent.java +++ b/app/src/main/java/com/limelight/nvstream/mdns/MdnsDiscoveryAgent.java @@ -1,148 +1,148 @@ -package com.limelight.nvstream.mdns; - -import com.limelight.LimeLog; - -import java.net.Inet4Address; -import java.net.Inet6Address; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; - -public abstract class MdnsDiscoveryAgent { - protected MdnsDiscoveryListener listener; - - protected HashSet computers = new HashSet<>(); - - public MdnsDiscoveryAgent(MdnsDiscoveryListener listener) { - this.listener = listener; - } - - public abstract void startDiscovery(final int discoveryIntervalMs); - - public abstract void stopDiscovery(); - - protected void reportNewComputer(String name, int port, Inet4Address[] v4Addrs, Inet6Address[] v6Addrs) { - LimeLog.info("mDNS: "+name+" has "+v4Addrs.length+" IPv4 addresses"); - LimeLog.info("mDNS: "+name+" has "+v6Addrs.length+" IPv6 addresses"); - - Inet6Address v6GlobalAddr = getBestIpv6Address(v6Addrs); - - // Add a computer object for each IPv4 address reported by the PC - for (Inet4Address v4Addr : v4Addrs) { - synchronized (computers) { - MdnsComputer computer = new MdnsComputer(name, v4Addr, v6GlobalAddr, port); - if (computers.add(computer)) { - // This was a new entry - listener.notifyComputerAdded(computer); - } - } - } - - // If there were no IPv4 addresses, use IPv6 for registration - if (v4Addrs.length == 0) { - Inet6Address v6LocalAddr = getLocalAddress(v6Addrs); - - if (v6LocalAddr != null || v6GlobalAddr != null) { - MdnsComputer computer = new MdnsComputer(name, v6LocalAddr, v6GlobalAddr, port); - if (computers.add(computer)) { - // This was a new entry - listener.notifyComputerAdded(computer); - } - } - } - } - - public List getComputerSet() { - synchronized (computers) { - return new ArrayList<>(computers); - } - } - - protected static Inet6Address getLocalAddress(Inet6Address[] addresses) { - for (Inet6Address addr : addresses) { - if (addr.isLinkLocalAddress() || addr.isSiteLocalAddress()) { - return addr; - } - // fc00::/7 - ULAs - else if ((addr.getAddress()[0] & 0xfe) == 0xfc) { - return addr; - } - } - - return null; - } - - protected static Inet6Address getLinkLocalAddress(Inet6Address[] addresses) { - for (Inet6Address addr : addresses) { - if (addr.isLinkLocalAddress()) { - LimeLog.info("Found link-local address: "+addr.getHostAddress()); - return addr; - } - } - - return null; - } - - protected static Inet6Address getBestIpv6Address(Inet6Address[] addresses) { - // First try to find a link local address, so we can match the interface identifier - // with a global address (this will work for SLAAC but not DHCPv6). - Inet6Address linkLocalAddr = getLinkLocalAddress(addresses); - - // We will try once to match a SLAAC interface suffix, then - // pick the first matching address - for (int tries = 0; tries < 2; tries++) { - // We assume the addresses are already sorted in descending order - // of preference from Bonjour. - for (Inet6Address addr : addresses) { - if (addr.isLinkLocalAddress() || addr.isSiteLocalAddress() || addr.isLoopbackAddress()) { - // Link-local, site-local, and loopback aren't global - LimeLog.info("Ignoring non-global address: "+addr.getHostAddress()); - continue; - } - - byte[] addrBytes = addr.getAddress(); - - // 2002::/16 - if (addrBytes[0] == 0x20 && addrBytes[1] == 0x02) { - // 6to4 has horrible performance - LimeLog.info("Ignoring 6to4 address: "+addr.getHostAddress()); - continue; - } - // 2001::/32 - else if (addrBytes[0] == 0x20 && addrBytes[1] == 0x01 && addrBytes[2] == 0x00 && addrBytes[3] == 0x00) { - // Teredo also has horrible performance - LimeLog.info("Ignoring Teredo address: "+addr.getHostAddress()); - continue; - } - // fc00::/7 - else if ((addrBytes[0] & 0xfe) == 0xfc) { - // ULAs aren't global - LimeLog.info("Ignoring ULA: "+addr.getHostAddress()); - continue; - } - - // Compare the final 64-bit interface identifier and skip the address - // if it doesn't match our link-local address. - if (linkLocalAddr != null && tries == 0) { - boolean matched = true; - - for (int i = 8; i < 16; i++) { - if (linkLocalAddr.getAddress()[i] != addr.getAddress()[i]) { - matched = false; - break; - } - } - - if (!matched) { - LimeLog.info("Ignoring non-matching global address: "+addr.getHostAddress()); - continue; - } - } - - return addr; - } - } - - return null; - } -} +package com.limelight.nvstream.mdns; + +import com.limelight.LimeLog; + +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; + +public abstract class MdnsDiscoveryAgent { + protected MdnsDiscoveryListener listener; + + protected HashSet computers = new HashSet<>(); + + public MdnsDiscoveryAgent(MdnsDiscoveryListener listener) { + this.listener = listener; + } + + public abstract void startDiscovery(final int discoveryIntervalMs); + + public abstract void stopDiscovery(); + + protected void reportNewComputer(String name, int port, Inet4Address[] v4Addrs, Inet6Address[] v6Addrs) { + LimeLog.info("mDNS: "+name+" has "+v4Addrs.length+" IPv4 addresses"); + LimeLog.info("mDNS: "+name+" has "+v6Addrs.length+" IPv6 addresses"); + + Inet6Address v6GlobalAddr = getBestIpv6Address(v6Addrs); + + // Add a computer object for each IPv4 address reported by the PC + for (Inet4Address v4Addr : v4Addrs) { + synchronized (computers) { + MdnsComputer computer = new MdnsComputer(name, v4Addr, v6GlobalAddr, port); + if (computers.add(computer)) { + // This was a new entry + listener.notifyComputerAdded(computer); + } + } + } + + // If there were no IPv4 addresses, use IPv6 for registration + if (v4Addrs.length == 0) { + Inet6Address v6LocalAddr = getLocalAddress(v6Addrs); + + if (v6LocalAddr != null || v6GlobalAddr != null) { + MdnsComputer computer = new MdnsComputer(name, v6LocalAddr, v6GlobalAddr, port); + if (computers.add(computer)) { + // This was a new entry + listener.notifyComputerAdded(computer); + } + } + } + } + + public List getComputerSet() { + synchronized (computers) { + return new ArrayList<>(computers); + } + } + + protected static Inet6Address getLocalAddress(Inet6Address[] addresses) { + for (Inet6Address addr : addresses) { + if (addr.isLinkLocalAddress() || addr.isSiteLocalAddress()) { + return addr; + } + // fc00::/7 - ULAs + else if ((addr.getAddress()[0] & 0xfe) == 0xfc) { + return addr; + } + } + + return null; + } + + protected static Inet6Address getLinkLocalAddress(Inet6Address[] addresses) { + for (Inet6Address addr : addresses) { + if (addr.isLinkLocalAddress()) { + LimeLog.info("Found link-local address: "+addr.getHostAddress()); + return addr; + } + } + + return null; + } + + protected static Inet6Address getBestIpv6Address(Inet6Address[] addresses) { + // First try to find a link local address, so we can match the interface identifier + // with a global address (this will work for SLAAC but not DHCPv6). + Inet6Address linkLocalAddr = getLinkLocalAddress(addresses); + + // We will try once to match a SLAAC interface suffix, then + // pick the first matching address + for (int tries = 0; tries < 2; tries++) { + // We assume the addresses are already sorted in descending order + // of preference from Bonjour. + for (Inet6Address addr : addresses) { + if (addr.isLinkLocalAddress() || addr.isSiteLocalAddress() || addr.isLoopbackAddress()) { + // Link-local, site-local, and loopback aren't global + LimeLog.info("Ignoring non-global address: "+addr.getHostAddress()); + continue; + } + + byte[] addrBytes = addr.getAddress(); + + // 2002::/16 + if (addrBytes[0] == 0x20 && addrBytes[1] == 0x02) { + // 6to4 has horrible performance + LimeLog.info("Ignoring 6to4 address: "+addr.getHostAddress()); + continue; + } + // 2001::/32 + else if (addrBytes[0] == 0x20 && addrBytes[1] == 0x01 && addrBytes[2] == 0x00 && addrBytes[3] == 0x00) { + // Teredo also has horrible performance + LimeLog.info("Ignoring Teredo address: "+addr.getHostAddress()); + continue; + } + // fc00::/7 + else if ((addrBytes[0] & 0xfe) == 0xfc) { + // ULAs aren't global + LimeLog.info("Ignoring ULA: "+addr.getHostAddress()); + continue; + } + + // Compare the final 64-bit interface identifier and skip the address + // if it doesn't match our link-local address. + if (linkLocalAddr != null && tries == 0) { + boolean matched = true; + + for (int i = 8; i < 16; i++) { + if (linkLocalAddr.getAddress()[i] != addr.getAddress()[i]) { + matched = false; + break; + } + } + + if (!matched) { + LimeLog.info("Ignoring non-matching global address: "+addr.getHostAddress()); + continue; + } + } + + return addr; + } + } + + return null; + } +} diff --git a/app/src/main/java/com/limelight/nvstream/mdns/MdnsDiscoveryListener.java b/app/src/main/java/com/limelight/nvstream/mdns/MdnsDiscoveryListener.java old mode 100644 new mode 100755 index fa4ce10c5a..8a85f0cc09 --- a/app/src/main/java/com/limelight/nvstream/mdns/MdnsDiscoveryListener.java +++ b/app/src/main/java/com/limelight/nvstream/mdns/MdnsDiscoveryListener.java @@ -1,6 +1,6 @@ -package com.limelight.nvstream.mdns; - -public interface MdnsDiscoveryListener { - void notifyComputerAdded(MdnsComputer computer); - void notifyDiscoveryFailure(Exception e); -} +package com.limelight.nvstream.mdns; + +public interface MdnsDiscoveryListener { + void notifyComputerAdded(MdnsComputer computer); + void notifyDiscoveryFailure(Exception e); +} diff --git a/app/src/main/java/com/limelight/nvstream/mdns/NsdManagerDiscoveryAgent.java b/app/src/main/java/com/limelight/nvstream/mdns/NsdManagerDiscoveryAgent.java old mode 100644 new mode 100755 index d94c94382c..b948ed78cd --- a/app/src/main/java/com/limelight/nvstream/mdns/NsdManagerDiscoveryAgent.java +++ b/app/src/main/java/com/limelight/nvstream/mdns/NsdManagerDiscoveryAgent.java @@ -1,234 +1,234 @@ -package com.limelight.nvstream.mdns; - -import android.annotation.TargetApi; -import android.content.Context; -import android.net.nsd.NsdManager; -import android.net.nsd.NsdServiceInfo; -import android.os.Build; - -import com.limelight.LimeLog; - -import java.net.Inet4Address; -import java.net.Inet6Address; -import java.net.InetAddress; -import java.util.HashMap; -import java.util.List; -import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.ThreadPoolExecutor; -import java.util.concurrent.TimeUnit; - -@TargetApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) -public class NsdManagerDiscoveryAgent extends MdnsDiscoveryAgent { - private static final String SERVICE_TYPE = "_nvstream._tcp"; - private final NsdManager nsdManager; - private final Object listenerLock = new Object(); - private NsdManager.DiscoveryListener pendingListener; - private NsdManager.DiscoveryListener activeListener; - private final HashMap serviceCallbacks = new HashMap<>(); - private final ThreadPoolExecutor executor = new ThreadPoolExecutor(1, 1, 0, TimeUnit.SECONDS, new LinkedBlockingQueue<>()); - - private NsdManager.DiscoveryListener createDiscoveryListener() { - return new NsdManager.DiscoveryListener() { - @Override - public void onStartDiscoveryFailed(String serviceType, int errorCode) { - LimeLog.severe("NSD: Service discovery start failed: " + errorCode); - - // This listener is no longer pending after this failure - synchronized (listenerLock) { - if (pendingListener != this) { - return; - } - - pendingListener = null; - } - - listener.notifyDiscoveryFailure(new RuntimeException("onStartDiscoveryFailed(): " + errorCode)); - } - - @Override - public void onStopDiscoveryFailed(String serviceType, int errorCode) { - LimeLog.severe("NSD: Service discovery stop failed: " + errorCode); - - // This listener is no longer active after this failure - synchronized (listenerLock) { - if (activeListener != this) { - return; - } - - activeListener = null; - } - } - - @Override - public void onDiscoveryStarted(String serviceType) { - LimeLog.info("NSD: Service discovery started"); - - synchronized (listenerLock) { - if (pendingListener != this) { - // If we registered another discovery listener in the meantime, stop this one - nsdManager.stopServiceDiscovery(this); - return; - } - - pendingListener = null; - activeListener = this; - } - } - - @Override - public void onDiscoveryStopped(String serviceType) { - LimeLog.info("NSD: Service discovery stopped"); - - synchronized (listenerLock) { - if (activeListener != this) { - return; - } - - activeListener = null; - } - } - - @Override - public void onServiceFound(NsdServiceInfo nsdServiceInfo) { - // Protect against racing stopDiscovery() call - synchronized (listenerLock) { - // Ignore callbacks if we're not the active listener - if (activeListener != this) { - return; - } - - LimeLog.info("NSD: Machine appeared: " + nsdServiceInfo.getServiceName()); - - NsdManager.ServiceInfoCallback serviceInfoCallback = new NsdManager.ServiceInfoCallback() { - @Override - public void onServiceInfoCallbackRegistrationFailed(int errorCode) { - LimeLog.severe("NSD: Service info callback registration failed: " + errorCode); - listener.notifyDiscoveryFailure(new RuntimeException("onServiceInfoCallbackRegistrationFailed(): " + errorCode)); - } - - @Override - public void onServiceUpdated(NsdServiceInfo nsdServiceInfo) { - LimeLog.info("NSD: Machine resolved: " + nsdServiceInfo.getServiceName()); - reportNewComputer(nsdServiceInfo.getServiceName(), nsdServiceInfo.getPort(), - getV4Addrs(nsdServiceInfo.getHostAddresses()), - getV6Addrs(nsdServiceInfo.getHostAddresses())); - } - - @Override - public void onServiceLost() { - } - - @Override - public void onServiceInfoCallbackUnregistered() { - } - }; - - nsdManager.registerServiceInfoCallback(nsdServiceInfo, executor, serviceInfoCallback); - serviceCallbacks.put(nsdServiceInfo.getServiceName(), serviceInfoCallback); - } - } - - @Override - public void onServiceLost(NsdServiceInfo nsdServiceInfo) { - // Protect against racing stopDiscovery() call - synchronized (listenerLock) { - // Ignore callbacks if we're not the active listener - if (activeListener != this) { - return; - } - - LimeLog.info("NSD: Machine lost: " + nsdServiceInfo.getServiceName()); - - NsdManager.ServiceInfoCallback serviceInfoCallback = serviceCallbacks.remove(nsdServiceInfo.getServiceName()); - if (serviceInfoCallback != null) { - nsdManager.unregisterServiceInfoCallback(serviceInfoCallback); - } - } - } - }; - } - - public NsdManagerDiscoveryAgent(Context context, MdnsDiscoveryListener listener) { - super(listener); - this.nsdManager = context.getSystemService(NsdManager.class); - } - - @Override - public void startDiscovery(int discoveryIntervalMs) { - synchronized (listenerLock) { - // Register a new service discovery listener if there's not already one starting or running - if (pendingListener == null && activeListener == null) { - pendingListener = createDiscoveryListener(); - nsdManager.discoverServices(SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, pendingListener); - } - } - } - - @Override - public void stopDiscovery() { - // Protect against racing ServiceInfoCallback and DiscoveryListener callbacks - synchronized (listenerLock) { - // Clear any pending listener to ensure the discoverStarted() callback - // will realize it's gone and stop itself. - pendingListener = null; - - // Unregister the service discovery listener - if (activeListener != null) { - nsdManager.stopServiceDiscovery(activeListener); - - // Even though listener stoppage is asynchronous, the listener is gone as far as - // we're concerned. We null this right now to ensure pending callbacks know it's - // stopped and startDiscovery() can immediately create a new listener. If we left - // it until onDiscoveryStopped() was called, startDiscovery() would get confused - // and assume a listener was already running, even though it's stopping. - activeListener = null; - } - - // Unregister all service info callbacks - for (NsdManager.ServiceInfoCallback callback : serviceCallbacks.values()) { - nsdManager.unregisterServiceInfoCallback(callback); - } - serviceCallbacks.clear(); - } - } - - private static Inet4Address[] getV4Addrs(List addrs) { - int matchCount = 0; - for (InetAddress addr : addrs) { - if (addr instanceof Inet4Address) { - matchCount++; - } - } - - Inet4Address[] matching = new Inet4Address[matchCount]; - - int i = 0; - for (InetAddress addr : addrs) { - if (addr instanceof Inet4Address) { - matching[i++] = (Inet4Address) addr; - } - } - - return matching; - } - - private static Inet6Address[] getV6Addrs(List addrs) { - int matchCount = 0; - for (InetAddress addr : addrs) { - if (addr instanceof Inet6Address) { - matchCount++; - } - } - - Inet6Address[] matching = new Inet6Address[matchCount]; - - int i = 0; - for (InetAddress addr : addrs) { - if (addr instanceof Inet6Address) { - matching[i++] = (Inet6Address) addr; - } - } - - return matching; - } -} +package com.limelight.nvstream.mdns; + +import android.annotation.TargetApi; +import android.content.Context; +import android.net.nsd.NsdManager; +import android.net.nsd.NsdServiceInfo; +import android.os.Build; + +import com.limelight.LimeLog; + +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.net.InetAddress; +import java.util.HashMap; +import java.util.List; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +@TargetApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) +public class NsdManagerDiscoveryAgent extends MdnsDiscoveryAgent { + private static final String SERVICE_TYPE = "_nvstream._tcp"; + private final NsdManager nsdManager; + private final Object listenerLock = new Object(); + private NsdManager.DiscoveryListener pendingListener; + private NsdManager.DiscoveryListener activeListener; + private final HashMap serviceCallbacks = new HashMap<>(); + private final ThreadPoolExecutor executor = new ThreadPoolExecutor(1, 1, 0, TimeUnit.SECONDS, new LinkedBlockingQueue<>()); + + private NsdManager.DiscoveryListener createDiscoveryListener() { + return new NsdManager.DiscoveryListener() { + @Override + public void onStartDiscoveryFailed(String serviceType, int errorCode) { + LimeLog.severe("NSD: Service discovery start failed: " + errorCode); + + // This listener is no longer pending after this failure + synchronized (listenerLock) { + if (pendingListener != this) { + return; + } + + pendingListener = null; + } + + listener.notifyDiscoveryFailure(new RuntimeException("onStartDiscoveryFailed(): " + errorCode)); + } + + @Override + public void onStopDiscoveryFailed(String serviceType, int errorCode) { + LimeLog.severe("NSD: Service discovery stop failed: " + errorCode); + + // This listener is no longer active after this failure + synchronized (listenerLock) { + if (activeListener != this) { + return; + } + + activeListener = null; + } + } + + @Override + public void onDiscoveryStarted(String serviceType) { + LimeLog.info("NSD: Service discovery started"); + + synchronized (listenerLock) { + if (pendingListener != this) { + // If we registered another discovery listener in the meantime, stop this one + nsdManager.stopServiceDiscovery(this); + return; + } + + pendingListener = null; + activeListener = this; + } + } + + @Override + public void onDiscoveryStopped(String serviceType) { + LimeLog.info("NSD: Service discovery stopped"); + + synchronized (listenerLock) { + if (activeListener != this) { + return; + } + + activeListener = null; + } + } + + @Override + public void onServiceFound(NsdServiceInfo nsdServiceInfo) { + // Protect against racing stopDiscovery() call + synchronized (listenerLock) { + // Ignore callbacks if we're not the active listener + if (activeListener != this) { + return; + } + + LimeLog.info("NSD: Machine appeared: " + nsdServiceInfo.getServiceName()); + + NsdManager.ServiceInfoCallback serviceInfoCallback = new NsdManager.ServiceInfoCallback() { + @Override + public void onServiceInfoCallbackRegistrationFailed(int errorCode) { + LimeLog.severe("NSD: Service info callback registration failed: " + errorCode); + listener.notifyDiscoveryFailure(new RuntimeException("onServiceInfoCallbackRegistrationFailed(): " + errorCode)); + } + + @Override + public void onServiceUpdated(NsdServiceInfo nsdServiceInfo) { + LimeLog.info("NSD: Machine resolved: " + nsdServiceInfo.getServiceName()); + reportNewComputer(nsdServiceInfo.getServiceName(), nsdServiceInfo.getPort(), + getV4Addrs(nsdServiceInfo.getHostAddresses()), + getV6Addrs(nsdServiceInfo.getHostAddresses())); + } + + @Override + public void onServiceLost() { + } + + @Override + public void onServiceInfoCallbackUnregistered() { + } + }; + + nsdManager.registerServiceInfoCallback(nsdServiceInfo, executor, serviceInfoCallback); + serviceCallbacks.put(nsdServiceInfo.getServiceName(), serviceInfoCallback); + } + } + + @Override + public void onServiceLost(NsdServiceInfo nsdServiceInfo) { + // Protect against racing stopDiscovery() call + synchronized (listenerLock) { + // Ignore callbacks if we're not the active listener + if (activeListener != this) { + return; + } + + LimeLog.info("NSD: Machine lost: " + nsdServiceInfo.getServiceName()); + + NsdManager.ServiceInfoCallback serviceInfoCallback = serviceCallbacks.remove(nsdServiceInfo.getServiceName()); + if (serviceInfoCallback != null) { + nsdManager.unregisterServiceInfoCallback(serviceInfoCallback); + } + } + } + }; + } + + public NsdManagerDiscoveryAgent(Context context, MdnsDiscoveryListener listener) { + super(listener); + this.nsdManager = context.getSystemService(NsdManager.class); + } + + @Override + public void startDiscovery(int discoveryIntervalMs) { + synchronized (listenerLock) { + // Register a new service discovery listener if there's not already one starting or running + if (pendingListener == null && activeListener == null) { + pendingListener = createDiscoveryListener(); + nsdManager.discoverServices(SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, pendingListener); + } + } + } + + @Override + public void stopDiscovery() { + // Protect against racing ServiceInfoCallback and DiscoveryListener callbacks + synchronized (listenerLock) { + // Clear any pending listener to ensure the discoverStarted() callback + // will realize it's gone and stop itself. + pendingListener = null; + + // Unregister the service discovery listener + if (activeListener != null) { + nsdManager.stopServiceDiscovery(activeListener); + + // Even though listener stoppage is asynchronous, the listener is gone as far as + // we're concerned. We null this right now to ensure pending callbacks know it's + // stopped and startDiscovery() can immediately create a new listener. If we left + // it until onDiscoveryStopped() was called, startDiscovery() would get confused + // and assume a listener was already running, even though it's stopping. + activeListener = null; + } + + // Unregister all service info callbacks + for (NsdManager.ServiceInfoCallback callback : serviceCallbacks.values()) { + nsdManager.unregisterServiceInfoCallback(callback); + } + serviceCallbacks.clear(); + } + } + + private static Inet4Address[] getV4Addrs(List addrs) { + int matchCount = 0; + for (InetAddress addr : addrs) { + if (addr instanceof Inet4Address) { + matchCount++; + } + } + + Inet4Address[] matching = new Inet4Address[matchCount]; + + int i = 0; + for (InetAddress addr : addrs) { + if (addr instanceof Inet4Address) { + matching[i++] = (Inet4Address) addr; + } + } + + return matching; + } + + private static Inet6Address[] getV6Addrs(List addrs) { + int matchCount = 0; + for (InetAddress addr : addrs) { + if (addr instanceof Inet6Address) { + matchCount++; + } + } + + Inet6Address[] matching = new Inet6Address[matchCount]; + + int i = 0; + for (InetAddress addr : addrs) { + if (addr instanceof Inet6Address) { + matching[i++] = (Inet6Address) addr; + } + } + + return matching; + } +} diff --git a/app/src/main/java/com/limelight/nvstream/wol/WakeOnLanSender.java b/app/src/main/java/com/limelight/nvstream/wol/WakeOnLanSender.java old mode 100644 new mode 100755 index 945f114b5b..0dcbc3fc65 --- a/app/src/main/java/com/limelight/nvstream/wol/WakeOnLanSender.java +++ b/app/src/main/java/com/limelight/nvstream/wol/WakeOnLanSender.java @@ -1,149 +1,149 @@ -package com.limelight.nvstream.wol; - -import java.io.IOException; -import java.net.DatagramPacket; -import java.net.DatagramSocket; -import java.net.InetAddress; -import java.util.Scanner; - -import com.limelight.LimeLog; -import com.limelight.nvstream.http.ComputerDetails; - -public class WakeOnLanSender { - // These ports will always be tried as-is. - private static final int[] STATIC_PORTS_TO_TRY = new int[] { - 9, // Standard WOL port (privileged port) - 47009, // Port opened by Moonlight Internet Hosting Tool for WoL (non-privileged port) - }; - - // These ports will be offset by the base port number (47989) to support alternate ports. - private static final int[] DYNAMIC_PORTS_TO_TRY = new int[] { - 47998, 47999, 48000, 48002, 48010, // Ports opened by GFE - }; - - private static void sendPacketsForAddress(InetAddress address, int httpPort, DatagramSocket sock, byte[] payload) throws IOException { - IOException lastException = null; - boolean sentWolPacket = false; - - // Try the static ports - for (int port : STATIC_PORTS_TO_TRY) { - try { - DatagramPacket dp = new DatagramPacket(payload, payload.length); - dp.setAddress(address); - dp.setPort(port); - sock.send(dp); - sentWolPacket = true; - } catch (IOException e) { - e.printStackTrace(); - lastException = e; - } - } - - // Try the dynamic ports - for (int port : DYNAMIC_PORTS_TO_TRY) { - try { - DatagramPacket dp = new DatagramPacket(payload, payload.length); - dp.setAddress(address); - dp.setPort((port - 47989) + httpPort); - sock.send(dp); - sentWolPacket = true; - } catch (IOException e) { - e.printStackTrace(); - lastException = e; - } - } - - if (!sentWolPacket) { - throw lastException; - } - } - - public static void sendWolPacket(ComputerDetails computer) throws IOException { - byte[] payload = createWolPayload(computer); - IOException lastException = null; - boolean sentWolPacket = false; - - try (final DatagramSocket sock = new DatagramSocket(0)) { - // Try all resolved remote and local addresses and broadcast addresses. - // The broadcast address is required to avoid stale ARP cache entries - // making the sleeping machine unreachable. - for (ComputerDetails.AddressTuple address : new ComputerDetails.AddressTuple[] { - computer.localAddress, computer.remoteAddress, - computer.manualAddress, computer.ipv6Address, - }) { - if (address == null) { - continue; - } - - try { - sendPacketsForAddress(InetAddress.getByName("255.255.255.255"), address.port, sock, payload); - sentWolPacket = true; - } catch (IOException e) { - e.printStackTrace(); - lastException = e; - } - - try { - for (InetAddress resolvedAddress : InetAddress.getAllByName(address.address)) { - try { - sendPacketsForAddress(resolvedAddress, address.port, sock, payload); - sentWolPacket = true; - } catch (IOException e) { - e.printStackTrace(); - lastException = e; - } - } - } catch (IOException e) { - // We may have addresses that don't resolve on this subnet, - // but don't throw and exit the whole function if that happens. - // We'll throw it at the end if we didn't send a single packet. - e.printStackTrace(); - lastException = e; - } - } - } - - // Propagate the DNS resolution exception if we didn't - // manage to get a single packet out to the host. - if (!sentWolPacket && lastException != null) { - throw lastException; - } - } - - private static byte[] macStringToBytes(String macAddress) { - byte[] macBytes = new byte[6]; - - try (@SuppressWarnings("resource") - final Scanner scan = new Scanner(macAddress).useDelimiter(":") - ) { - for (int i = 0; i < macBytes.length && scan.hasNext(); i++) { - try { - macBytes[i] = (byte) Integer.parseInt(scan.next(), 16); - } catch (NumberFormatException e) { - LimeLog.warning("Malformed MAC address: " + macAddress + " (index: " + i + ")"); - break; - } - } - return macBytes; - } - } - - private static byte[] createWolPayload(ComputerDetails computer) { - byte[] payload = new byte[102]; - byte[] macAddress = macStringToBytes(computer.macAddress); - int i; - - // 6 bytes of FF - for (i = 0; i < 6; i++) { - payload[i] = (byte)0xFF; - } - - // 16 repetitions of the MAC address - for (int j = 0; j < 16; j++) { - System.arraycopy(macAddress, 0, payload, i, macAddress.length); - i += macAddress.length; - } - - return payload; - } -} +package com.limelight.nvstream.wol; + +import java.io.IOException; +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.util.Scanner; + +import com.limelight.LimeLog; +import com.limelight.nvstream.http.ComputerDetails; + +public class WakeOnLanSender { + // These ports will always be tried as-is. + private static final int[] STATIC_PORTS_TO_TRY = new int[] { + 9, // Standard WOL port (privileged port) + 47009, // Port opened by Moonlight Internet Hosting Tool for WoL (non-privileged port) + }; + + // These ports will be offset by the base port number (47989) to support alternate ports. + private static final int[] DYNAMIC_PORTS_TO_TRY = new int[] { + 47998, 47999, 48000, 48002, 48010, // Ports opened by GFE + }; + + private static void sendPacketsForAddress(InetAddress address, int httpPort, DatagramSocket sock, byte[] payload) throws IOException { + IOException lastException = null; + boolean sentWolPacket = false; + + // Try the static ports + for (int port : STATIC_PORTS_TO_TRY) { + try { + DatagramPacket dp = new DatagramPacket(payload, payload.length); + dp.setAddress(address); + dp.setPort(port); + sock.send(dp); + sentWolPacket = true; + } catch (IOException e) { + e.printStackTrace(); + lastException = e; + } + } + + // Try the dynamic ports + for (int port : DYNAMIC_PORTS_TO_TRY) { + try { + DatagramPacket dp = new DatagramPacket(payload, payload.length); + dp.setAddress(address); + dp.setPort((port - 47989) + httpPort); + sock.send(dp); + sentWolPacket = true; + } catch (IOException e) { + e.printStackTrace(); + lastException = e; + } + } + + if (!sentWolPacket) { + throw lastException; + } + } + + public static void sendWolPacket(ComputerDetails computer) throws IOException { + byte[] payload = createWolPayload(computer); + IOException lastException = null; + boolean sentWolPacket = false; + + try (final DatagramSocket sock = new DatagramSocket(0)) { + // Try all resolved remote and local addresses and broadcast addresses. + // The broadcast address is required to avoid stale ARP cache entries + // making the sleeping machine unreachable. + for (ComputerDetails.AddressTuple address : new ComputerDetails.AddressTuple[] { + computer.localAddress, computer.remoteAddress, + computer.manualAddress, computer.ipv6Address, + }) { + if (address == null) { + continue; + } + + try { + sendPacketsForAddress(InetAddress.getByName("255.255.255.255"), address.port, sock, payload); + sentWolPacket = true; + } catch (IOException e) { + e.printStackTrace(); + lastException = e; + } + + try { + for (InetAddress resolvedAddress : InetAddress.getAllByName(address.address)) { + try { + sendPacketsForAddress(resolvedAddress, address.port, sock, payload); + sentWolPacket = true; + } catch (IOException e) { + e.printStackTrace(); + lastException = e; + } + } + } catch (IOException e) { + // We may have addresses that don't resolve on this subnet, + // but don't throw and exit the whole function if that happens. + // We'll throw it at the end if we didn't send a single packet. + e.printStackTrace(); + lastException = e; + } + } + } + + // Propagate the DNS resolution exception if we didn't + // manage to get a single packet out to the host. + if (!sentWolPacket && lastException != null) { + throw lastException; + } + } + + private static byte[] macStringToBytes(String macAddress) { + byte[] macBytes = new byte[6]; + + try (@SuppressWarnings("resource") + final Scanner scan = new Scanner(macAddress).useDelimiter(":") + ) { + for (int i = 0; i < macBytes.length && scan.hasNext(); i++) { + try { + macBytes[i] = (byte) Integer.parseInt(scan.next(), 16); + } catch (NumberFormatException e) { + LimeLog.warning("Malformed MAC address: " + macAddress + " (index: " + i + ")"); + break; + } + } + return macBytes; + } + } + + private static byte[] createWolPayload(ComputerDetails computer) { + byte[] payload = new byte[102]; + byte[] macAddress = macStringToBytes(computer.macAddress); + int i; + + // 6 bytes of FF + for (i = 0; i < 6; i++) { + payload[i] = (byte)0xFF; + } + + // 16 repetitions of the MAC address + for (int j = 0; j < 16; j++) { + System.arraycopy(macAddress, 0, payload, i, macAddress.length); + i += macAddress.length; + } + + return payload; + } +} diff --git a/app/src/main/java/com/limelight/preferences/AddComputerManually.java b/app/src/main/java/com/limelight/preferences/AddComputerManually.java old mode 100644 new mode 100755 index febb655892..247b6494de --- a/app/src/main/java/com/limelight/preferences/AddComputerManually.java +++ b/app/src/main/java/com/limelight/preferences/AddComputerManually.java @@ -1,323 +1,323 @@ -package com.limelight.preferences; - -import java.io.IOException; -import java.net.Inet4Address; -import java.net.InetAddress; -import java.net.InterfaceAddress; -import java.net.NetworkInterface; -import java.net.SocketException; -import java.net.URI; -import java.net.URISyntaxException; -import java.net.UnknownHostException; -import java.util.Collections; -import java.util.concurrent.LinkedBlockingQueue; - -import com.limelight.binding.PlatformBinding; -import com.limelight.computers.ComputerManagerService; -import com.limelight.R; -import com.limelight.nvstream.http.ComputerDetails; -import com.limelight.nvstream.http.NvHTTP; -import com.limelight.nvstream.jni.MoonBridge; -import com.limelight.utils.Dialog; -import com.limelight.utils.ServerHelper; -import com.limelight.utils.SpinnerDialog; -import com.limelight.utils.UiHelper; - -import android.app.Activity; -import android.app.Service; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.ServiceConnection; -import android.os.Bundle; -import android.os.IBinder; -import android.view.KeyEvent; -import android.view.View; -import android.view.inputmethod.EditorInfo; -import android.view.inputmethod.InputMethodManager; -import android.widget.TextView; -import android.widget.Toast; - -public class AddComputerManually extends Activity { - private TextView hostText; - private ComputerManagerService.ComputerManagerBinder managerBinder; - private final LinkedBlockingQueue computersToAdd = new LinkedBlockingQueue<>(); - private Thread addThread; - private final ServiceConnection serviceConnection = new ServiceConnection() { - public void onServiceConnected(ComponentName className, final IBinder binder) { - managerBinder = ((ComputerManagerService.ComputerManagerBinder)binder); - startAddThread(); - } - - public void onServiceDisconnected(ComponentName className) { - joinAddThread(); - managerBinder = null; - } - }; - - private boolean isWrongSubnetSiteLocalAddress(String address) { - try { - InetAddress targetAddress = InetAddress.getByName(address); - if (!(targetAddress instanceof Inet4Address) || !targetAddress.isSiteLocalAddress()) { - return false; - } - - // We have a site-local address. Look for a matching local interface. - for (NetworkInterface iface : Collections.list(NetworkInterface.getNetworkInterfaces())) { - for (InterfaceAddress addr : iface.getInterfaceAddresses()) { - if (!(addr.getAddress() instanceof Inet4Address) || !addr.getAddress().isSiteLocalAddress()) { - // Skip non-site-local or non-IPv4 addresses - continue; - } - - byte[] targetAddrBytes = targetAddress.getAddress(); - byte[] ifaceAddrBytes = addr.getAddress().getAddress(); - - // Compare prefix to ensure it's the same - boolean addressMatches = true; - for (int i = 0; i < addr.getNetworkPrefixLength(); i++) { - if ((ifaceAddrBytes[i / 8] & (1 << (i % 8))) != (targetAddrBytes[i / 8] & (1 << (i % 8)))) { - addressMatches = false; - break; - } - } - - if (addressMatches) { - return false; - } - } - } - - // Couldn't find a matching interface - return true; - } catch (Exception e) { - // Catch all exceptions because some broken Android devices - // will throw an NPE from inside getNetworkInterfaces(). - e.printStackTrace(); - return false; - } - } - - private URI parseRawUserInputToUri(String rawUserInput) { - try { - // Try adding a scheme and parsing the remaining input. - // This handles input like 127.0.0.1:47989, [::1], [::1]:47989, and 127.0.0.1. - URI uri = new URI("moonlight://" + rawUserInput); - if (uri.getHost() != null && !uri.getHost().isEmpty()) { - return uri; - } - } catch (URISyntaxException ignored) {} - - try { - // Attempt to escape the input as an IPv6 literal. - // This handles input like ::1. - URI uri = new URI("moonlight://[" + rawUserInput + "]"); - if (uri.getHost() != null && !uri.getHost().isEmpty()) { - return uri; - } - } catch (URISyntaxException ignored) {} - - return null; - } - - private void doAddPc(String rawUserInput) throws InterruptedException { - boolean wrongSiteLocal = false; - boolean invalidInput = false; - boolean success; - int portTestResult; - - SpinnerDialog dialog = SpinnerDialog.displayDialog(this, getResources().getString(R.string.title_add_pc), - getResources().getString(R.string.msg_add_pc), false); - - try { - ComputerDetails details = new ComputerDetails(); - - // Check if we parsed a host address successfully - URI uri = parseRawUserInputToUri(rawUserInput); - if (uri != null && uri.getHost() != null && !uri.getHost().isEmpty()) { - String host = uri.getHost(); - int port = uri.getPort(); - - // If a port was not specified, use the default - if (port == -1) { - port = NvHTTP.DEFAULT_HTTP_PORT; - } - - details.manualAddress = new ComputerDetails.AddressTuple(host, port); - success = managerBinder.addComputerBlocking(details); - if (!success){ - wrongSiteLocal = isWrongSubnetSiteLocalAddress(host); - } - } else { - // Invalid user input - success = false; - invalidInput = true; - } - } catch (InterruptedException e) { - // Propagate the InterruptedException to the caller for proper handling - dialog.dismiss(); - throw e; - } catch (IllegalArgumentException e) { - // This can be thrown from OkHttp if the host fails to canonicalize to a valid name. - // https://github.com/square/okhttp/blob/okhttp_27/okhttp/src/main/java/com/squareup/okhttp/HttpUrl.java#L705 - e.printStackTrace(); - success = false; - invalidInput = true; - } - - // Keep the SpinnerDialog open while testing connectivity - if (!success && !wrongSiteLocal && !invalidInput) { - // Run the test before dismissing the spinner because it can take a few seconds. - portTestResult = MoonBridge.testClientConnectivity(ServerHelper.CONNECTION_TEST_SERVER, 443, - MoonBridge.ML_PORT_FLAG_TCP_47984 | MoonBridge.ML_PORT_FLAG_TCP_47989); - } else { - // Don't bother with the test if we succeeded or the IP address was bogus - portTestResult = MoonBridge.ML_TEST_RESULT_INCONCLUSIVE; - } - - dialog.dismiss(); - - if (invalidInput) { - Dialog.displayDialog(this, getResources().getString(R.string.conn_error_title), getResources().getString(R.string.addpc_unknown_host), false); - } - else if (wrongSiteLocal) { - Dialog.displayDialog(this, getResources().getString(R.string.conn_error_title), getResources().getString(R.string.addpc_wrong_sitelocal), false); - } - else if (!success) { - String dialogText; - if (portTestResult != MoonBridge.ML_TEST_RESULT_INCONCLUSIVE && portTestResult != 0) { - dialogText = getResources().getString(R.string.nettest_text_blocked); - } - else { - dialogText = getResources().getString(R.string.addpc_fail); - } - Dialog.displayDialog(this, getResources().getString(R.string.conn_error_title), dialogText, false); - } - else { - AddComputerManually.this.runOnUiThread(new Runnable() { - @Override - public void run() { - Toast.makeText(AddComputerManually.this, getResources().getString(R.string.addpc_success), Toast.LENGTH_LONG).show(); - - if (!isFinishing()) { - // Close the activity - AddComputerManually.this.finish(); - } - } - }); - } - - } - - private void startAddThread() { - addThread = new Thread() { - @Override - public void run() { - while (!isInterrupted()) { - try { - String computer = computersToAdd.take(); - doAddPc(computer); - } catch (InterruptedException e) { - return; - } - } - } - }; - addThread.setName("UI - AddComputerManually"); - addThread.start(); - } - - private void joinAddThread() { - if (addThread != null) { - addThread.interrupt(); - - try { - addThread.join(); - } catch (InterruptedException e) { - e.printStackTrace(); - - // InterruptedException clears the thread's interrupt status. Since we can't - // handle that here, we will re-interrupt the thread to set the interrupt - // status back to true. - Thread.currentThread().interrupt(); - } - - addThread = null; - } - } - - @Override - protected void onStop() { - super.onStop(); - - Dialog.closeDialogs(); - SpinnerDialog.closeDialogs(this); - } - - @Override - protected void onDestroy() { - super.onDestroy(); - - if (managerBinder != null) { - joinAddThread(); - unbindService(serviceConnection); - } - } - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - UiHelper.setLocale(this); - - setContentView(R.layout.activity_add_computer_manually); - - UiHelper.notifyNewRootView(this); - - this.hostText = findViewById(R.id.hostTextView); - hostText.setImeOptions(EditorInfo.IME_ACTION_DONE); - hostText.setOnEditorActionListener(new TextView.OnEditorActionListener() { - @Override - public boolean onEditorAction(TextView textView, int actionId, KeyEvent keyEvent) { - if (actionId == EditorInfo.IME_ACTION_DONE || - (keyEvent != null && - keyEvent.getAction() == KeyEvent.ACTION_DOWN && - keyEvent.getKeyCode() == KeyEvent.KEYCODE_ENTER)) { - return handleDoneEvent(); - } - else if (actionId == EditorInfo.IME_ACTION_PREVIOUS) { - // This is how the Fire TV dismisses the keyboard - InputMethodManager imm = (InputMethodManager)getSystemService(Context.INPUT_METHOD_SERVICE); - imm.hideSoftInputFromWindow(hostText.getWindowToken(), 0); - return false; - } - - return false; - } - }); - - findViewById(R.id.addPcButton).setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View view) { - handleDoneEvent(); - } - }); - - // Bind to the ComputerManager service - bindService(new Intent(AddComputerManually.this, - ComputerManagerService.class), serviceConnection, Service.BIND_AUTO_CREATE); - } - - // Returns true if the event should be eaten - private boolean handleDoneEvent() { - String hostAddress = hostText.getText().toString().trim(); - - if (hostAddress.length() == 0) { - Toast.makeText(AddComputerManually.this, getResources().getString(R.string.addpc_enter_ip), Toast.LENGTH_LONG).show(); - return true; - } - - computersToAdd.add(hostAddress); - return false; - } -} +package com.limelight.preferences; + +import java.io.IOException; +import java.net.Inet4Address; +import java.net.InetAddress; +import java.net.InterfaceAddress; +import java.net.NetworkInterface; +import java.net.SocketException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.UnknownHostException; +import java.util.Collections; +import java.util.concurrent.LinkedBlockingQueue; + +import com.limelight.binding.PlatformBinding; +import com.limelight.computers.ComputerManagerService; +import com.limelight.R; +import com.limelight.nvstream.http.ComputerDetails; +import com.limelight.nvstream.http.NvHTTP; +import com.limelight.nvstream.jni.MoonBridge; +import com.limelight.utils.Dialog; +import com.limelight.utils.ServerHelper; +import com.limelight.utils.SpinnerDialog; +import com.limelight.utils.UiHelper; + +import android.app.Activity; +import android.app.Service; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.Bundle; +import android.os.IBinder; +import android.view.KeyEvent; +import android.view.View; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputMethodManager; +import android.widget.TextView; +import android.widget.Toast; + +public class AddComputerManually extends Activity { + private TextView hostText; + private ComputerManagerService.ComputerManagerBinder managerBinder; + private final LinkedBlockingQueue computersToAdd = new LinkedBlockingQueue<>(); + private Thread addThread; + private final ServiceConnection serviceConnection = new ServiceConnection() { + public void onServiceConnected(ComponentName className, final IBinder binder) { + managerBinder = ((ComputerManagerService.ComputerManagerBinder)binder); + startAddThread(); + } + + public void onServiceDisconnected(ComponentName className) { + joinAddThread(); + managerBinder = null; + } + }; + + private boolean isWrongSubnetSiteLocalAddress(String address) { + try { + InetAddress targetAddress = InetAddress.getByName(address); + if (!(targetAddress instanceof Inet4Address) || !targetAddress.isSiteLocalAddress()) { + return false; + } + + // We have a site-local address. Look for a matching local interface. + for (NetworkInterface iface : Collections.list(NetworkInterface.getNetworkInterfaces())) { + for (InterfaceAddress addr : iface.getInterfaceAddresses()) { + if (!(addr.getAddress() instanceof Inet4Address) || !addr.getAddress().isSiteLocalAddress()) { + // Skip non-site-local or non-IPv4 addresses + continue; + } + + byte[] targetAddrBytes = targetAddress.getAddress(); + byte[] ifaceAddrBytes = addr.getAddress().getAddress(); + + // Compare prefix to ensure it's the same + boolean addressMatches = true; + for (int i = 0; i < addr.getNetworkPrefixLength(); i++) { + if ((ifaceAddrBytes[i / 8] & (1 << (i % 8))) != (targetAddrBytes[i / 8] & (1 << (i % 8)))) { + addressMatches = false; + break; + } + } + + if (addressMatches) { + return false; + } + } + } + + // Couldn't find a matching interface + return true; + } catch (Exception e) { + // Catch all exceptions because some broken Android devices + // will throw an NPE from inside getNetworkInterfaces(). + e.printStackTrace(); + return false; + } + } + + private URI parseRawUserInputToUri(String rawUserInput) { + try { + // Try adding a scheme and parsing the remaining input. + // This handles input like 127.0.0.1:47989, [::1], [::1]:47989, and 127.0.0.1. + URI uri = new URI("moonlight://" + rawUserInput); + if (uri.getHost() != null && !uri.getHost().isEmpty()) { + return uri; + } + } catch (URISyntaxException ignored) {} + + try { + // Attempt to escape the input as an IPv6 literal. + // This handles input like ::1. + URI uri = new URI("moonlight://[" + rawUserInput + "]"); + if (uri.getHost() != null && !uri.getHost().isEmpty()) { + return uri; + } + } catch (URISyntaxException ignored) {} + + return null; + } + + private void doAddPc(String rawUserInput) throws InterruptedException { + boolean wrongSiteLocal = false; + boolean invalidInput = false; + boolean success; + int portTestResult; + + SpinnerDialog dialog = SpinnerDialog.displayDialog(this, getResources().getString(R.string.title_add_pc), + getResources().getString(R.string.msg_add_pc), false); + + try { + ComputerDetails details = new ComputerDetails(); + + // Check if we parsed a host address successfully + URI uri = parseRawUserInputToUri(rawUserInput); + if (uri != null && uri.getHost() != null && !uri.getHost().isEmpty()) { + String host = uri.getHost(); + int port = uri.getPort(); + + // If a port was not specified, use the default + if (port == -1) { + port = NvHTTP.DEFAULT_HTTP_PORT; + } + + details.manualAddress = new ComputerDetails.AddressTuple(host, port); + success = managerBinder.addComputerBlocking(details); + if (!success){ + wrongSiteLocal = isWrongSubnetSiteLocalAddress(host); + } + } else { + // Invalid user input + success = false; + invalidInput = true; + } + } catch (InterruptedException e) { + // Propagate the InterruptedException to the caller for proper handling + dialog.dismiss(); + throw e; + } catch (IllegalArgumentException e) { + // This can be thrown from OkHttp if the host fails to canonicalize to a valid name. + // https://github.com/square/okhttp/blob/okhttp_27/okhttp/src/main/java/com/squareup/okhttp/HttpUrl.java#L705 + e.printStackTrace(); + success = false; + invalidInput = true; + } + + // Keep the SpinnerDialog open while testing connectivity + if (!success && !wrongSiteLocal && !invalidInput) { + // Run the test before dismissing the spinner because it can take a few seconds. + portTestResult = MoonBridge.testClientConnectivity(ServerHelper.CONNECTION_TEST_SERVER, 443, + MoonBridge.ML_PORT_FLAG_TCP_47984 | MoonBridge.ML_PORT_FLAG_TCP_47989); + } else { + // Don't bother with the test if we succeeded or the IP address was bogus + portTestResult = MoonBridge.ML_TEST_RESULT_INCONCLUSIVE; + } + + dialog.dismiss(); + + if (invalidInput) { + Dialog.displayDialog(this, getResources().getString(R.string.conn_error_title), getResources().getString(R.string.addpc_unknown_host), false); + } + else if (wrongSiteLocal) { + Dialog.displayDialog(this, getResources().getString(R.string.conn_error_title), getResources().getString(R.string.addpc_wrong_sitelocal), false); + } + else if (!success) { + String dialogText; + if (portTestResult != MoonBridge.ML_TEST_RESULT_INCONCLUSIVE && portTestResult != 0) { + dialogText = getResources().getString(R.string.nettest_text_blocked); + } + else { + dialogText = getResources().getString(R.string.addpc_fail); + } + Dialog.displayDialog(this, getResources().getString(R.string.conn_error_title), dialogText, false); + } + else { + AddComputerManually.this.runOnUiThread(new Runnable() { + @Override + public void run() { + Toast.makeText(AddComputerManually.this, getResources().getString(R.string.addpc_success), Toast.LENGTH_LONG).show(); + + if (!isFinishing()) { + // Close the activity + AddComputerManually.this.finish(); + } + } + }); + } + + } + + private void startAddThread() { + addThread = new Thread() { + @Override + public void run() { + while (!isInterrupted()) { + try { + String computer = computersToAdd.take(); + doAddPc(computer); + } catch (InterruptedException e) { + return; + } + } + } + }; + addThread.setName("UI - AddComputerManually"); + addThread.start(); + } + + private void joinAddThread() { + if (addThread != null) { + addThread.interrupt(); + + try { + addThread.join(); + } catch (InterruptedException e) { + e.printStackTrace(); + + // InterruptedException clears the thread's interrupt status. Since we can't + // handle that here, we will re-interrupt the thread to set the interrupt + // status back to true. + Thread.currentThread().interrupt(); + } + + addThread = null; + } + } + + @Override + protected void onStop() { + super.onStop(); + + Dialog.closeDialogs(); + SpinnerDialog.closeDialogs(this); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + + if (managerBinder != null) { + joinAddThread(); + unbindService(serviceConnection); + } + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + UiHelper.setLocale(this); + + setContentView(R.layout.activity_add_computer_manually); + + UiHelper.notifyNewRootView(this); + + this.hostText = findViewById(R.id.hostTextView); + hostText.setImeOptions(EditorInfo.IME_ACTION_DONE); + hostText.setOnEditorActionListener(new TextView.OnEditorActionListener() { + @Override + public boolean onEditorAction(TextView textView, int actionId, KeyEvent keyEvent) { + if (actionId == EditorInfo.IME_ACTION_DONE || + (keyEvent != null && + keyEvent.getAction() == KeyEvent.ACTION_DOWN && + keyEvent.getKeyCode() == KeyEvent.KEYCODE_ENTER)) { + return handleDoneEvent(); + } + else if (actionId == EditorInfo.IME_ACTION_PREVIOUS) { + // This is how the Fire TV dismisses the keyboard + InputMethodManager imm = (InputMethodManager)getSystemService(Context.INPUT_METHOD_SERVICE); + imm.hideSoftInputFromWindow(hostText.getWindowToken(), 0); + return false; + } + + return false; + } + }); + + findViewById(R.id.addPcButton).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + handleDoneEvent(); + } + }); + + // Bind to the ComputerManager service + bindService(new Intent(AddComputerManually.this, + ComputerManagerService.class), serviceConnection, Service.BIND_AUTO_CREATE); + } + + // Returns true if the event should be eaten + private boolean handleDoneEvent() { + String hostAddress = hostText.getText().toString().trim(); + + if (hostAddress.length() == 0) { + Toast.makeText(AddComputerManually.this, getResources().getString(R.string.addpc_enter_ip), Toast.LENGTH_LONG).show(); + return true; + } + + computersToAdd.add(hostAddress); + return false; + } +} diff --git a/app/src/main/java/com/limelight/preferences/ConfirmDeleteKeyboardPreference.java b/app/src/main/java/com/limelight/preferences/ConfirmDeleteKeyboardPreference.java new file mode 100755 index 0000000000..67eb58fee8 --- /dev/null +++ b/app/src/main/java/com/limelight/preferences/ConfirmDeleteKeyboardPreference.java @@ -0,0 +1,43 @@ +package com.limelight.preferences; + +import android.annotation.TargetApi; +import android.content.Context; +import android.content.DialogInterface; +import android.os.Build; +import android.preference.DialogPreference; +import android.preference.PreferenceManager; +import android.util.AttributeSet; +import android.widget.Toast; + +import com.limelight.R; + +import static com.limelight.binding.input.virtual_controller.keyboard.KeyBoardControllerConfigurationLoader.OSC_PREFERENCE; +import static com.limelight.binding.input.virtual_controller.keyboard.KeyBoardControllerConfigurationLoader.OSC_PREFERENCE_VALUE; + +public class ConfirmDeleteKeyboardPreference extends DialogPreference { + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + public ConfirmDeleteKeyboardPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + public ConfirmDeleteKeyboardPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public ConfirmDeleteKeyboardPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + public ConfirmDeleteKeyboardPreference(Context context) { + super(context); + } + + public void onClick(DialogInterface dialog, int which) { + if (which == DialogInterface.BUTTON_POSITIVE) { + String name= PreferenceManager.getDefaultSharedPreferences(getContext()).getString(OSC_PREFERENCE,OSC_PREFERENCE_VALUE); + getContext().getSharedPreferences(name, Context.MODE_PRIVATE).edit().clear().apply(); + Toast.makeText(getContext(), R.string.toast_reset_osc_success, Toast.LENGTH_SHORT).show(); + } + } +} diff --git a/app/src/main/java/com/limelight/preferences/ConfirmDeleteOscPreference.java b/app/src/main/java/com/limelight/preferences/ConfirmDeleteOscPreference.java old mode 100644 new mode 100755 index 01f5c34073..a6b5beb8e1 --- a/app/src/main/java/com/limelight/preferences/ConfirmDeleteOscPreference.java +++ b/app/src/main/java/com/limelight/preferences/ConfirmDeleteOscPreference.java @@ -1,38 +1,38 @@ -package com.limelight.preferences; - -import android.annotation.TargetApi; -import android.content.Context; -import android.content.DialogInterface; -import android.os.Build; -import android.preference.DialogPreference; -import android.util.AttributeSet; -import android.widget.Toast; - -import com.limelight.R; - -import static com.limelight.binding.input.virtual_controller.VirtualControllerConfigurationLoader.OSC_PREFERENCE; - -public class ConfirmDeleteOscPreference extends DialogPreference { - public ConfirmDeleteOscPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { - super(context, attrs, defStyleAttr, defStyleRes); - } - - public ConfirmDeleteOscPreference(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - } - - public ConfirmDeleteOscPreference(Context context, AttributeSet attrs) { - super(context, attrs); - } - - public ConfirmDeleteOscPreference(Context context) { - super(context); - } - - public void onClick(DialogInterface dialog, int which) { - if (which == DialogInterface.BUTTON_POSITIVE) { - getContext().getSharedPreferences(OSC_PREFERENCE, Context.MODE_PRIVATE).edit().clear().apply(); - Toast.makeText(getContext(), R.string.toast_reset_osc_success, Toast.LENGTH_SHORT).show(); - } - } -} +package com.limelight.preferences; + +import android.annotation.TargetApi; +import android.content.Context; +import android.content.DialogInterface; +import android.os.Build; +import android.preference.DialogPreference; +import android.util.AttributeSet; +import android.widget.Toast; + +import com.limelight.R; + +import static com.limelight.binding.input.virtual_controller.VirtualControllerConfigurationLoader.OSC_PREFERENCE; + +public class ConfirmDeleteOscPreference extends DialogPreference { + public ConfirmDeleteOscPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + public ConfirmDeleteOscPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public ConfirmDeleteOscPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public ConfirmDeleteOscPreference(Context context) { + super(context); + } + + public void onClick(DialogInterface dialog, int which) { + if (which == DialogInterface.BUTTON_POSITIVE) { + getContext().getSharedPreferences(OSC_PREFERENCE, Context.MODE_PRIVATE).edit().clear().apply(); + Toast.makeText(getContext(), R.string.toast_reset_osc_success, Toast.LENGTH_SHORT).show(); + } + } +} diff --git a/app/src/main/java/com/limelight/preferences/GlPreferences.java b/app/src/main/java/com/limelight/preferences/GlPreferences.java old mode 100644 new mode 100755 index d245f4cc8a..41f2eb6f83 --- a/app/src/main/java/com/limelight/preferences/GlPreferences.java +++ b/app/src/main/java/com/limelight/preferences/GlPreferences.java @@ -1,37 +1,37 @@ -package com.limelight.preferences; - - -import android.content.Context; -import android.content.SharedPreferences; - -public class GlPreferences { - private static final String PREF_NAME = "GlPreferences"; - - private static final String FINGERPRINT_PREF_STRING = "Fingerprint"; - private static final String GL_RENDERER_PREF_STRING = "Renderer"; - - private SharedPreferences prefs; - public String glRenderer; - public String savedFingerprint; - - private GlPreferences(SharedPreferences prefs) { - this.prefs = prefs; - } - - public static GlPreferences readPreferences(Context context) { - SharedPreferences prefs = context.getSharedPreferences(PREF_NAME, 0); - GlPreferences glPrefs = new GlPreferences(prefs); - - glPrefs.glRenderer = prefs.getString(GL_RENDERER_PREF_STRING, ""); - glPrefs.savedFingerprint = prefs.getString(FINGERPRINT_PREF_STRING, ""); - - return glPrefs; - } - - public boolean writePreferences() { - return prefs.edit() - .putString(GL_RENDERER_PREF_STRING, glRenderer) - .putString(FINGERPRINT_PREF_STRING, savedFingerprint) - .commit(); - } -} +package com.limelight.preferences; + + +import android.content.Context; +import android.content.SharedPreferences; + +public class GlPreferences { + private static final String PREF_NAME = "GlPreferences"; + + private static final String FINGERPRINT_PREF_STRING = "Fingerprint"; + private static final String GL_RENDERER_PREF_STRING = "Renderer"; + + private SharedPreferences prefs; + public String glRenderer; + public String savedFingerprint; + + private GlPreferences(SharedPreferences prefs) { + this.prefs = prefs; + } + + public static GlPreferences readPreferences(Context context) { + SharedPreferences prefs = context.getSharedPreferences(PREF_NAME, 0); + GlPreferences glPrefs = new GlPreferences(prefs); + + glPrefs.glRenderer = prefs.getString(GL_RENDERER_PREF_STRING, ""); + glPrefs.savedFingerprint = prefs.getString(FINGERPRINT_PREF_STRING, ""); + + return glPrefs; + } + + public boolean writePreferences() { + return prefs.edit() + .putString(GL_RENDERER_PREF_STRING, glRenderer) + .putString(FINGERPRINT_PREF_STRING, savedFingerprint) + .commit(); + } +} diff --git a/app/src/main/java/com/limelight/preferences/LanguagePreference.java b/app/src/main/java/com/limelight/preferences/LanguagePreference.java old mode 100644 new mode 100755 index 6549b01512..83120dd425 --- a/app/src/main/java/com/limelight/preferences/LanguagePreference.java +++ b/app/src/main/java/com/limelight/preferences/LanguagePreference.java @@ -1,49 +1,49 @@ -package com.limelight.preferences; - -import android.annotation.TargetApi; -import android.content.ActivityNotFoundException; -import android.content.Context; -import android.content.Intent; -import android.net.Uri; -import android.os.Build; -import android.preference.ListPreference; -import android.provider.Settings; -import android.util.AttributeSet; - -public class LanguagePreference extends ListPreference { - public LanguagePreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { - super(context, attrs, defStyleAttr, defStyleRes); - } - - public LanguagePreference(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - } - - public LanguagePreference(Context context, AttributeSet attrs) { - super(context, attrs); - } - - public LanguagePreference(Context context) { - super(context); - } - - @Override - protected void onClick() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - try { - // Launch the Android native app locale settings page - Intent intent = new Intent(Settings.ACTION_APP_LOCALE_SETTINGS); - intent.addCategory(Intent.CATEGORY_DEFAULT); - intent.setData(Uri.parse("package:" + getContext().getPackageName())); - getContext().startActivity(intent, null); - return; - } catch (ActivityNotFoundException e) { - // App locale settings should be present on all Android 13 devices, - // but if not, we'll launch the old language chooser. - } - } - - // If we don't have native app locale settings, launch the normal dialog - super.onClick(); - } -} +package com.limelight.preferences; + +import android.annotation.TargetApi; +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.preference.ListPreference; +import android.provider.Settings; +import android.util.AttributeSet; + +public class LanguagePreference extends ListPreference { + public LanguagePreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + public LanguagePreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public LanguagePreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public LanguagePreference(Context context) { + super(context); + } + + @Override + protected void onClick() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + try { + // Launch the Android native app locale settings page + Intent intent = new Intent(Settings.ACTION_APP_LOCALE_SETTINGS); + intent.addCategory(Intent.CATEGORY_DEFAULT); + intent.setData(Uri.parse("package:" + getContext().getPackageName())); + getContext().startActivity(intent, null); + return; + } catch (ActivityNotFoundException e) { + // App locale settings should be present on all Android 13 devices, + // but if not, we'll launch the old language chooser. + } + } + + // If we don't have native app locale settings, launch the normal dialog + super.onClick(); + } +} diff --git a/app/src/main/java/com/limelight/preferences/PreferenceConfiguration.java b/app/src/main/java/com/limelight/preferences/PreferenceConfiguration.java old mode 100644 new mode 100755 index e0ba76e513..a512bf3b0e --- a/app/src/main/java/com/limelight/preferences/PreferenceConfiguration.java +++ b/app/src/main/java/com/limelight/preferences/PreferenceConfiguration.java @@ -1,603 +1,737 @@ -package com.limelight.preferences; - -import android.content.Context; -import android.content.SharedPreferences; -import android.content.pm.PackageManager; -import android.os.Build; -import android.preference.PreferenceManager; -import android.view.Display; - -import com.limelight.nvstream.jni.MoonBridge; - -public class PreferenceConfiguration { - public enum FormatOption { - AUTO, - FORCE_AV1, - FORCE_HEVC, - FORCE_H264, - }; - - public enum AnalogStickForScrolling { - NONE, - RIGHT, - LEFT - } - - private static final String LEGACY_RES_FPS_PREF_STRING = "list_resolution_fps"; - private static final String LEGACY_ENABLE_51_SURROUND_PREF_STRING = "checkbox_51_surround"; - - static final String RESOLUTION_PREF_STRING = "list_resolution"; - static final String FPS_PREF_STRING = "list_fps"; - static final String BITRATE_PREF_STRING = "seekbar_bitrate_kbps"; - private static final String BITRATE_PREF_OLD_STRING = "seekbar_bitrate"; - private static final String STRETCH_PREF_STRING = "checkbox_stretch_video"; - private static final String SOPS_PREF_STRING = "checkbox_enable_sops"; - private static final String DISABLE_TOASTS_PREF_STRING = "checkbox_disable_warnings"; - private static final String HOST_AUDIO_PREF_STRING = "checkbox_host_audio"; - private static final String DEADZONE_PREF_STRING = "seekbar_deadzone"; - private static final String OSC_OPACITY_PREF_STRING = "seekbar_osc_opacity"; - private static final String LANGUAGE_PREF_STRING = "list_languages"; - private static final String SMALL_ICONS_PREF_STRING = "checkbox_small_icon_mode"; - private static final String MULTI_CONTROLLER_PREF_STRING = "checkbox_multi_controller"; - static final String AUDIO_CONFIG_PREF_STRING = "list_audio_config"; - private static final String USB_DRIVER_PREF_SRING = "checkbox_usb_driver"; - private static final String VIDEO_FORMAT_PREF_STRING = "video_format"; - private static final String ONSCREEN_CONTROLLER_PREF_STRING = "checkbox_show_onscreen_controls"; - private static final String ONLY_L3_R3_PREF_STRING = "checkbox_only_show_L3R3"; - private static final String LEGACY_DISABLE_FRAME_DROP_PREF_STRING = "checkbox_disable_frame_drop"; - private static final String ENABLE_HDR_PREF_STRING = "checkbox_enable_hdr"; - private static final String ENABLE_PIP_PREF_STRING = "checkbox_enable_pip"; - private static final String ENABLE_PERF_OVERLAY_STRING = "checkbox_enable_perf_overlay"; - private static final String BIND_ALL_USB_STRING = "checkbox_usb_bind_all"; - private static final String MOUSE_EMULATION_STRING = "checkbox_mouse_emulation"; - private static final String ANALOG_SCROLLING_PREF_STRING = "analog_scrolling"; - private static final String MOUSE_NAV_BUTTONS_STRING = "checkbox_mouse_nav_buttons"; - static final String UNLOCK_FPS_STRING = "checkbox_unlock_fps"; - private static final String VIBRATE_OSC_PREF_STRING = "checkbox_vibrate_osc"; - private static final String VIBRATE_FALLBACK_PREF_STRING = "checkbox_vibrate_fallback"; - private static final String VIBRATE_FALLBACK_STRENGTH_PREF_STRING = "seekbar_vibrate_fallback_strength"; - private static final String FLIP_FACE_BUTTONS_PREF_STRING = "checkbox_flip_face_buttons"; - private static final String TOUCHSCREEN_TRACKPAD_PREF_STRING = "checkbox_touchscreen_trackpad"; - private static final String LATENCY_TOAST_PREF_STRING = "checkbox_enable_post_stream_toast"; - private static final String FRAME_PACING_PREF_STRING = "frame_pacing"; - private static final String ABSOLUTE_MOUSE_MODE_PREF_STRING = "checkbox_absolute_mouse_mode"; - private static final String ENABLE_AUDIO_FX_PREF_STRING = "checkbox_enable_audiofx"; - private static final String REDUCE_REFRESH_RATE_PREF_STRING = "checkbox_reduce_refresh_rate"; - private static final String FULL_RANGE_PREF_STRING = "checkbox_full_range"; - private static final String GAMEPAD_TOUCHPAD_AS_MOUSE_PREF_STRING = "checkbox_gamepad_touchpad_as_mouse"; - private static final String GAMEPAD_MOTION_SENSORS_PREF_STRING = "checkbox_gamepad_motion_sensors"; - private static final String GAMEPAD_MOTION_FALLBACK_PREF_STRING = "checkbox_gamepad_motion_fallback"; - - static final String DEFAULT_RESOLUTION = "1280x720"; - static final String DEFAULT_FPS = "60"; - private static final boolean DEFAULT_STRETCH = false; - private static final boolean DEFAULT_SOPS = true; - private static final boolean DEFAULT_DISABLE_TOASTS = false; - private static final boolean DEFAULT_HOST_AUDIO = false; - private static final int DEFAULT_DEADZONE = 7; - private static final int DEFAULT_OPACITY = 90; - public static final String DEFAULT_LANGUAGE = "default"; - private static final boolean DEFAULT_MULTI_CONTROLLER = true; - private static final boolean DEFAULT_USB_DRIVER = true; - private static final String DEFAULT_VIDEO_FORMAT = "auto"; - - private static final boolean ONSCREEN_CONTROLLER_DEFAULT = false; - private static final boolean ONLY_L3_R3_DEFAULT = false; - private static final boolean DEFAULT_ENABLE_HDR = false; - private static final boolean DEFAULT_ENABLE_PIP = false; - private static final boolean DEFAULT_ENABLE_PERF_OVERLAY = false; - private static final boolean DEFAULT_BIND_ALL_USB = false; - private static final boolean DEFAULT_MOUSE_EMULATION = true; - private static final String DEFAULT_ANALOG_STICK_FOR_SCROLLING = "right"; - private static final boolean DEFAULT_MOUSE_NAV_BUTTONS = false; - private static final boolean DEFAULT_UNLOCK_FPS = false; - private static final boolean DEFAULT_VIBRATE_OSC = true; - private static final boolean DEFAULT_VIBRATE_FALLBACK = false; - private static final int DEFAULT_VIBRATE_FALLBACK_STRENGTH = 100; - private static final boolean DEFAULT_FLIP_FACE_BUTTONS = false; - private static final boolean DEFAULT_TOUCHSCREEN_TRACKPAD = true; - private static final String DEFAULT_AUDIO_CONFIG = "2"; // Stereo - private static final boolean DEFAULT_LATENCY_TOAST = false; - private static final String DEFAULT_FRAME_PACING = "latency"; - private static final boolean DEFAULT_ABSOLUTE_MOUSE_MODE = false; - private static final boolean DEFAULT_ENABLE_AUDIO_FX = false; - private static final boolean DEFAULT_REDUCE_REFRESH_RATE = false; - private static final boolean DEFAULT_FULL_RANGE = false; - private static final boolean DEFAULT_GAMEPAD_TOUCHPAD_AS_MOUSE = false; - private static final boolean DEFAULT_GAMEPAD_MOTION_SENSORS = true; - private static final boolean DEFAULT_GAMEPAD_MOTION_FALLBACK = false; - - public static final int FRAME_PACING_MIN_LATENCY = 0; - public static final int FRAME_PACING_BALANCED = 1; - public static final int FRAME_PACING_CAP_FPS = 2; - public static final int FRAME_PACING_MAX_SMOOTHNESS = 3; - - public static final String RES_360P = "640x360"; - public static final String RES_480P = "854x480"; - public static final String RES_720P = "1280x720"; - public static final String RES_1080P = "1920x1080"; - public static final String RES_1440P = "2560x1440"; - public static final String RES_4K = "3840x2160"; - public static final String RES_NATIVE = "Native"; - - public int width, height, fps; - public int bitrate; - public FormatOption videoFormat; - public int deadzonePercentage; - public int oscOpacity; - public boolean stretchVideo, enableSops, playHostAudio, disableWarnings; - public String language; - public boolean smallIconMode, multiController, usbDriver, flipFaceButtons; - public boolean onscreenController; - public boolean onlyL3R3; - public boolean enableHdr; - public boolean enablePip; - public boolean enablePerfOverlay; - public boolean enableLatencyToast; - public boolean bindAllUsb; - public boolean mouseEmulation; - public AnalogStickForScrolling analogStickForScrolling; - public boolean mouseNavButtons; - public boolean unlockFps; - public boolean vibrateOsc; - public boolean vibrateFallbackToDevice; - public int vibrateFallbackToDeviceStrength; - public boolean touchscreenTrackpad; - public MoonBridge.AudioConfiguration audioConfiguration; - public int framePacing; - public boolean absoluteMouseMode; - public boolean enableAudioFx; - public boolean reduceRefreshRate; - public boolean fullRange; - public boolean gamepadMotionSensors; - public boolean gamepadTouchpadAsMouse; - public boolean gamepadMotionSensorsFallbackToDevice; - - public static boolean isNativeResolution(int width, int height) { - // It's not a native resolution if it matches an existing resolution option - if (width == 640 && height == 360) { - return false; - } - else if (width == 854 && height == 480) { - return false; - } - else if (width == 1280 && height == 720) { - return false; - } - else if (width == 1920 && height == 1080) { - return false; - } - else if (width == 2560 && height == 1440) { - return false; - } - else if (width == 3840 && height == 2160) { - return false; - } - - return true; - } - - // If we have a screen that has semi-square dimensions, we may want to change our behavior - // to allow any orientation and vertical+horizontal resolutions. - public static boolean isSquarishScreen(int width, int height) { - float longDim = Math.max(width, height); - float shortDim = Math.min(width, height); - - // We just put the arbitrary cutoff for a square-ish screen at 1.3 - return longDim / shortDim < 1.3f; - } - - public static boolean isSquarishScreen(Display display) { - int width, height; - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - width = display.getMode().getPhysicalWidth(); - height = display.getMode().getPhysicalHeight(); - } - else { - width = display.getWidth(); - height = display.getHeight(); - } - - return isSquarishScreen(width, height); - } - - private static String convertFromLegacyResolutionString(String resString) { - if (resString.equalsIgnoreCase("360p")) { - return RES_360P; - } - else if (resString.equalsIgnoreCase("480p")) { - return RES_480P; - } - else if (resString.equalsIgnoreCase("720p")) { - return RES_720P; - } - else if (resString.equalsIgnoreCase("1080p")) { - return RES_1080P; - } - else if (resString.equalsIgnoreCase("1440p")) { - return RES_1440P; - } - else if (resString.equalsIgnoreCase("4K")) { - return RES_4K; - } - else { - // Should be unreachable - return RES_720P; - } - } - - private static int getWidthFromResolutionString(String resString) { - return Integer.parseInt(resString.split("x")[0]); - } - - private static int getHeightFromResolutionString(String resString) { - return Integer.parseInt(resString.split("x")[1]); - } - - private static String getResolutionString(int width, int height) { - switch (height) { - case 360: - return RES_360P; - case 480: - return RES_480P; - default: - case 720: - return RES_720P; - case 1080: - return RES_1080P; - case 1440: - return RES_1440P; - case 2160: - return RES_4K; - } - } - - public static int getDefaultBitrate(String resString, String fpsString) { - int width = getWidthFromResolutionString(resString); - int height = getHeightFromResolutionString(resString); - int fps = Integer.parseInt(fpsString); - - // This logic is shamelessly stolen from Moonlight Qt: - // https://github.com/moonlight-stream/moonlight-qt/blob/master/app/settings/streamingpreferences.cpp - - // Don't scale bitrate linearly beyond 60 FPS. It's definitely not a linear - // bitrate increase for frame rate once we get to values that high. - double frameRateFactor = (fps <= 60 ? fps : (Math.sqrt(fps / 60.f) * 60.f)) / 30.f; - - // TODO: Collect some empirical data to see if these defaults make sense. - // We're just using the values that the Shield used, as we have for years. - int[] pixelVals = { - 640 * 360, - 854 * 480, - 1280 * 720, - 1920 * 1080, - 2560 * 1440, - 3840 * 2160, - -1, - }; - int[] factorVals = { - 1, - 2, - 5, - 10, - 20, - 40, - -1 - }; - - // Calculate the resolution factor by linear interpolation of the resolution table - float resolutionFactor; - int pixels = width * height; - for (int i = 0; ; i++) { - if (pixels == pixelVals[i]) { - // We can bail immediately for exact matches - resolutionFactor = factorVals[i]; - break; - } - else if (pixels < pixelVals[i]) { - if (i == 0) { - // Never go below the lowest resolution entry - resolutionFactor = factorVals[i]; - } - else { - // Interpolate between the entry greater than the chosen resolution (i) and the entry less than the chosen resolution (i-1) - resolutionFactor = ((float)(pixels - pixelVals[i-1]) / (pixelVals[i] - pixelVals[i-1])) * (factorVals[i] - factorVals[i-1]) + factorVals[i-1]; - } - break; - } - else if (pixelVals[i] == -1) { - // Never go above the highest resolution entry - resolutionFactor = factorVals[i-1]; - break; - } - } - - return (int)Math.round(resolutionFactor * frameRateFactor) * 1000; - } - - public static boolean getDefaultSmallMode(Context context) { - PackageManager manager = context.getPackageManager(); - if (manager != null) { - // TVs shouldn't use small mode by default - if (manager.hasSystemFeature(PackageManager.FEATURE_TELEVISION)) { - return false; - } - - // API 21 uses LEANBACK instead of TELEVISION - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { - if (manager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)) { - return false; - } - } - } - - // Use small mode on anything smaller than a 7" tablet - return context.getResources().getConfiguration().smallestScreenWidthDp < 500; - } - - public static int getDefaultBitrate(Context context) { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - return getDefaultBitrate( - prefs.getString(RESOLUTION_PREF_STRING, DEFAULT_RESOLUTION), - prefs.getString(FPS_PREF_STRING, DEFAULT_FPS)); - } - - private static FormatOption getVideoFormatValue(Context context) { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - - String str = prefs.getString(VIDEO_FORMAT_PREF_STRING, DEFAULT_VIDEO_FORMAT); - if (str.equals("auto")) { - return FormatOption.AUTO; - } - else if (str.equals("forceav1")) { - return FormatOption.FORCE_AV1; - } - else if (str.equals("forceh265")) { - return FormatOption.FORCE_HEVC; - } - else if (str.equals("neverh265")) { - return FormatOption.FORCE_H264; - } - else { - // Should never get here - return FormatOption.AUTO; - } - } - - private static int getFramePacingValue(Context context) { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - - // Migrate legacy never drop frames option to the new location - if (prefs.contains(LEGACY_DISABLE_FRAME_DROP_PREF_STRING)) { - boolean legacyNeverDropFrames = prefs.getBoolean(LEGACY_DISABLE_FRAME_DROP_PREF_STRING, false); - prefs.edit() - .remove(LEGACY_DISABLE_FRAME_DROP_PREF_STRING) - .putString(FRAME_PACING_PREF_STRING, legacyNeverDropFrames ? "balanced" : "latency") - .apply(); - } - - String str = prefs.getString(FRAME_PACING_PREF_STRING, DEFAULT_FRAME_PACING); - if (str.equals("latency")) { - return FRAME_PACING_MIN_LATENCY; - } - else if (str.equals("balanced")) { - return FRAME_PACING_BALANCED; - } - else if (str.equals("cap-fps")) { - return FRAME_PACING_CAP_FPS; - } - else if (str.equals("smoothness")) { - return FRAME_PACING_MAX_SMOOTHNESS; - } - else { - // Should never get here - return FRAME_PACING_MIN_LATENCY; - } - } - - private static AnalogStickForScrolling getAnalogStickForScrollingValue(Context context) { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - - String str = prefs.getString(ANALOG_SCROLLING_PREF_STRING, DEFAULT_ANALOG_STICK_FOR_SCROLLING); - if (str.equals("right")) { - return AnalogStickForScrolling.RIGHT; - } - else if (str.equals("left")) { - return AnalogStickForScrolling.LEFT; - } - else { - return AnalogStickForScrolling.NONE; - } - } - - public static void resetStreamingSettings(Context context) { - // We consider resolution, FPS, bitrate, HDR, and video format as "streaming settings" here - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - prefs.edit() - .remove(BITRATE_PREF_STRING) - .remove(BITRATE_PREF_OLD_STRING) - .remove(LEGACY_RES_FPS_PREF_STRING) - .remove(RESOLUTION_PREF_STRING) - .remove(FPS_PREF_STRING) - .remove(VIDEO_FORMAT_PREF_STRING) - .remove(ENABLE_HDR_PREF_STRING) - .remove(UNLOCK_FPS_STRING) - .remove(FULL_RANGE_PREF_STRING) - .apply(); - } - - public static void completeLanguagePreferenceMigration(Context context) { - // Put our language option back to default which tells us that we've already migrated it - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - prefs.edit().putString(LANGUAGE_PREF_STRING, DEFAULT_LANGUAGE).apply(); - } - - public static boolean isShieldAtvFirmwareWithBrokenHdr() { - // This particular Shield TV firmware crashes when using HDR - // https://www.nvidia.com/en-us/geforce/forums/notifications/comment/155192/ - return Build.MANUFACTURER.equalsIgnoreCase("NVIDIA") && - Build.FINGERPRINT.contains("PPR1.180610.011/4079208_2235.1395"); - } - - public static PreferenceConfiguration readPreferences(Context context) { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - PreferenceConfiguration config = new PreferenceConfiguration(); - - // Migrate legacy preferences to the new locations - if (prefs.contains(LEGACY_ENABLE_51_SURROUND_PREF_STRING)) { - if (prefs.getBoolean(LEGACY_ENABLE_51_SURROUND_PREF_STRING, false)) { - prefs.edit() - .remove(LEGACY_ENABLE_51_SURROUND_PREF_STRING) - .putString(AUDIO_CONFIG_PREF_STRING, "51") - .apply(); - } - } - - String str = prefs.getString(LEGACY_RES_FPS_PREF_STRING, null); - if (str != null) { - if (str.equals("360p30")) { - config.width = 640; - config.height = 360; - config.fps = 30; - } - else if (str.equals("360p60")) { - config.width = 640; - config.height = 360; - config.fps = 60; - } - else if (str.equals("720p30")) { - config.width = 1280; - config.height = 720; - config.fps = 30; - } - else if (str.equals("720p60")) { - config.width = 1280; - config.height = 720; - config.fps = 60; - } - else if (str.equals("1080p30")) { - config.width = 1920; - config.height = 1080; - config.fps = 30; - } - else if (str.equals("1080p60")) { - config.width = 1920; - config.height = 1080; - config.fps = 60; - } - else if (str.equals("4K30")) { - config.width = 3840; - config.height = 2160; - config.fps = 30; - } - else if (str.equals("4K60")) { - config.width = 3840; - config.height = 2160; - config.fps = 60; - } - else { - // Should never get here - config.width = 1280; - config.height = 720; - config.fps = 60; - } - - prefs.edit() - .remove(LEGACY_RES_FPS_PREF_STRING) - .putString(RESOLUTION_PREF_STRING, getResolutionString(config.width, config.height)) - .putString(FPS_PREF_STRING, ""+config.fps) - .apply(); - } - else { - // Use the new preference location - String resStr = prefs.getString(RESOLUTION_PREF_STRING, PreferenceConfiguration.DEFAULT_RESOLUTION); - - // Convert legacy resolution strings to the new style - if (!resStr.contains("x")) { - resStr = PreferenceConfiguration.convertFromLegacyResolutionString(resStr); - prefs.edit().putString(RESOLUTION_PREF_STRING, resStr).apply(); - } - - config.width = PreferenceConfiguration.getWidthFromResolutionString(resStr); - config.height = PreferenceConfiguration.getHeightFromResolutionString(resStr); - config.fps = Integer.parseInt(prefs.getString(FPS_PREF_STRING, PreferenceConfiguration.DEFAULT_FPS)); - } - - if (!prefs.contains(SMALL_ICONS_PREF_STRING)) { - // We need to write small icon mode's default to disk for the settings page to display - // the current state of the option properly - prefs.edit().putBoolean(SMALL_ICONS_PREF_STRING, getDefaultSmallMode(context)).apply(); - } - - if (!prefs.contains(GAMEPAD_MOTION_SENSORS_PREF_STRING) && Build.VERSION.SDK_INT == Build.VERSION_CODES.S) { - // Android 12 has a nasty bug that causes crashes when the app touches the InputDevice's - // associated InputDeviceSensorManager (just calling getSensorManager() is enough). - // As a workaround, we will override the default value for the gamepad motion sensor - // option to disabled on Android 12 to reduce the impact of this bug. - // https://cs.android.com/android/_/android/platform/frameworks/base/+/8970010a5e9f3dc5c069f56b4147552accfcbbeb - prefs.edit().putBoolean(GAMEPAD_MOTION_SENSORS_PREF_STRING, false).apply(); - } - - // This must happen after the preferences migration to ensure the preferences are populated - config.bitrate = prefs.getInt(BITRATE_PREF_STRING, prefs.getInt(BITRATE_PREF_OLD_STRING, 0) * 1000); - if (config.bitrate == 0) { - config.bitrate = getDefaultBitrate(context); - } - - String audioConfig = prefs.getString(AUDIO_CONFIG_PREF_STRING, DEFAULT_AUDIO_CONFIG); - if (audioConfig.equals("71")) { - config.audioConfiguration = MoonBridge.AUDIO_CONFIGURATION_71_SURROUND; - } - else if (audioConfig.equals("51")) { - config.audioConfiguration = MoonBridge.AUDIO_CONFIGURATION_51_SURROUND; - } - else /* if (audioConfig.equals("2")) */ { - config.audioConfiguration = MoonBridge.AUDIO_CONFIGURATION_STEREO; - } - - config.videoFormat = getVideoFormatValue(context); - config.framePacing = getFramePacingValue(context); - - config.analogStickForScrolling = getAnalogStickForScrollingValue(context); - - config.deadzonePercentage = prefs.getInt(DEADZONE_PREF_STRING, DEFAULT_DEADZONE); - - config.oscOpacity = prefs.getInt(OSC_OPACITY_PREF_STRING, DEFAULT_OPACITY); - - config.language = prefs.getString(LANGUAGE_PREF_STRING, DEFAULT_LANGUAGE); - - // Checkbox preferences - config.disableWarnings = prefs.getBoolean(DISABLE_TOASTS_PREF_STRING, DEFAULT_DISABLE_TOASTS); - config.enableSops = prefs.getBoolean(SOPS_PREF_STRING, DEFAULT_SOPS); - config.stretchVideo = prefs.getBoolean(STRETCH_PREF_STRING, DEFAULT_STRETCH); - config.playHostAudio = prefs.getBoolean(HOST_AUDIO_PREF_STRING, DEFAULT_HOST_AUDIO); - config.smallIconMode = prefs.getBoolean(SMALL_ICONS_PREF_STRING, getDefaultSmallMode(context)); - config.multiController = prefs.getBoolean(MULTI_CONTROLLER_PREF_STRING, DEFAULT_MULTI_CONTROLLER); - config.usbDriver = prefs.getBoolean(USB_DRIVER_PREF_SRING, DEFAULT_USB_DRIVER); - config.onscreenController = prefs.getBoolean(ONSCREEN_CONTROLLER_PREF_STRING, ONSCREEN_CONTROLLER_DEFAULT); - config.onlyL3R3 = prefs.getBoolean(ONLY_L3_R3_PREF_STRING, ONLY_L3_R3_DEFAULT); - config.enableHdr = prefs.getBoolean(ENABLE_HDR_PREF_STRING, DEFAULT_ENABLE_HDR) && !isShieldAtvFirmwareWithBrokenHdr(); - config.enablePip = prefs.getBoolean(ENABLE_PIP_PREF_STRING, DEFAULT_ENABLE_PIP); - config.enablePerfOverlay = prefs.getBoolean(ENABLE_PERF_OVERLAY_STRING, DEFAULT_ENABLE_PERF_OVERLAY); - config.bindAllUsb = prefs.getBoolean(BIND_ALL_USB_STRING, DEFAULT_BIND_ALL_USB); - config.mouseEmulation = prefs.getBoolean(MOUSE_EMULATION_STRING, DEFAULT_MOUSE_EMULATION); - config.mouseNavButtons = prefs.getBoolean(MOUSE_NAV_BUTTONS_STRING, DEFAULT_MOUSE_NAV_BUTTONS); - config.unlockFps = prefs.getBoolean(UNLOCK_FPS_STRING, DEFAULT_UNLOCK_FPS); - config.vibrateOsc = prefs.getBoolean(VIBRATE_OSC_PREF_STRING, DEFAULT_VIBRATE_OSC); - config.vibrateFallbackToDevice = prefs.getBoolean(VIBRATE_FALLBACK_PREF_STRING, DEFAULT_VIBRATE_FALLBACK); - config.vibrateFallbackToDeviceStrength = prefs.getInt(VIBRATE_FALLBACK_STRENGTH_PREF_STRING, DEFAULT_VIBRATE_FALLBACK_STRENGTH); - config.flipFaceButtons = prefs.getBoolean(FLIP_FACE_BUTTONS_PREF_STRING, DEFAULT_FLIP_FACE_BUTTONS); - config.touchscreenTrackpad = prefs.getBoolean(TOUCHSCREEN_TRACKPAD_PREF_STRING, DEFAULT_TOUCHSCREEN_TRACKPAD); - config.enableLatencyToast = prefs.getBoolean(LATENCY_TOAST_PREF_STRING, DEFAULT_LATENCY_TOAST); - config.absoluteMouseMode = prefs.getBoolean(ABSOLUTE_MOUSE_MODE_PREF_STRING, DEFAULT_ABSOLUTE_MOUSE_MODE); - config.enableAudioFx = prefs.getBoolean(ENABLE_AUDIO_FX_PREF_STRING, DEFAULT_ENABLE_AUDIO_FX); - config.reduceRefreshRate = prefs.getBoolean(REDUCE_REFRESH_RATE_PREF_STRING, DEFAULT_REDUCE_REFRESH_RATE); - config.fullRange = prefs.getBoolean(FULL_RANGE_PREF_STRING, DEFAULT_FULL_RANGE); - config.gamepadTouchpadAsMouse = prefs.getBoolean(GAMEPAD_TOUCHPAD_AS_MOUSE_PREF_STRING, DEFAULT_GAMEPAD_TOUCHPAD_AS_MOUSE); - config.gamepadMotionSensors = prefs.getBoolean(GAMEPAD_MOTION_SENSORS_PREF_STRING, DEFAULT_GAMEPAD_MOTION_SENSORS); - config.gamepadMotionSensorsFallbackToDevice = prefs.getBoolean(GAMEPAD_MOTION_FALLBACK_PREF_STRING, DEFAULT_GAMEPAD_MOTION_FALLBACK); - - return config; - } -} +package com.limelight.preferences; + +import android.content.Context; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.os.Build; +import android.preference.PreferenceManager; +import android.view.Display; + +import com.limelight.nvstream.jni.MoonBridge; + +public class PreferenceConfiguration { + public enum FormatOption { + AUTO, + FORCE_AV1, + FORCE_HEVC, + FORCE_H264, + }; + + public enum AnalogStickForScrolling { + NONE, + RIGHT, + LEFT + } + + private static final String LEGACY_RES_FPS_PREF_STRING = "list_resolution_fps"; + private static final String LEGACY_ENABLE_51_SURROUND_PREF_STRING = "checkbox_51_surround"; + + static final String RESOLUTION_PREF_STRING = "list_resolution"; + static final String FPS_PREF_STRING = "list_fps"; + static final String BITRATE_PREF_STRING = "seekbar_bitrate_kbps"; + private static final String BITRATE_PREF_OLD_STRING = "seekbar_bitrate"; + private static final String STRETCH_PREF_STRING = "checkbox_stretch_video"; + private static final String SOPS_PREF_STRING = "checkbox_enable_sops"; + private static final String DISABLE_TOASTS_PREF_STRING = "checkbox_disable_warnings"; + private static final String HOST_AUDIO_PREF_STRING = "checkbox_host_audio"; + private static final String DEADZONE_PREF_STRING = "seekbar_deadzone"; + private static final String OSC_OPACITY_PREF_STRING = "seekbar_osc_opacity"; + private static final String LANGUAGE_PREF_STRING = "list_languages"; + private static final String SMALL_ICONS_PREF_STRING = "checkbox_small_icon_mode"; + private static final String MULTI_CONTROLLER_PREF_STRING = "checkbox_multi_controller"; + static final String AUDIO_CONFIG_PREF_STRING = "list_audio_config"; + private static final String USB_DRIVER_PREF_SRING = "checkbox_usb_driver"; + private static final String VIDEO_FORMAT_PREF_STRING = "video_format"; + private static final String ONSCREEN_CONTROLLER_PREF_STRING = "checkbox_show_onscreen_controls"; + private static final String ONLY_L3_R3_PREF_STRING = "checkbox_only_show_L3R3"; + private static final String LEGACY_DISABLE_FRAME_DROP_PREF_STRING = "checkbox_disable_frame_drop"; + private static final String ENABLE_HDR_PREF_STRING = "checkbox_enable_hdr"; + private static final String ENABLE_PIP_PREF_STRING = "checkbox_enable_pip"; + private static final String ENABLE_PERF_OVERLAY_STRING = "checkbox_enable_perf_overlay"; + private static final String BIND_ALL_USB_STRING = "checkbox_usb_bind_all"; + private static final String MOUSE_EMULATION_STRING = "checkbox_mouse_emulation"; + private static final String ANALOG_SCROLLING_PREF_STRING = "analog_scrolling"; + private static final String MOUSE_NAV_BUTTONS_STRING = "checkbox_mouse_nav_buttons"; + static final String UNLOCK_FPS_STRING = "checkbox_unlock_fps"; + private static final String VIBRATE_OSC_PREF_STRING = "checkbox_vibrate_osc"; + private static final String VIBRATE_FALLBACK_PREF_STRING = "checkbox_vibrate_fallback"; + private static final String VIBRATE_FALLBACK_STRENGTH_PREF_STRING = "seekbar_vibrate_fallback_strength"; + private static final String FLIP_FACE_BUTTONS_PREF_STRING = "checkbox_flip_face_buttons"; +// static final String TOUCHSCREEN_TRACKPAD_PREF_STRING = "checkbox_touchscreen_trackpad"; + private static final String LATENCY_TOAST_PREF_STRING = "checkbox_enable_post_stream_toast"; + private static final String FRAME_PACING_PREF_STRING = "frame_pacing"; + private static final String ABSOLUTE_MOUSE_MODE_PREF_STRING = "checkbox_absolute_mouse_mode"; + private static final String ENABLE_AUDIO_FX_PREF_STRING = "checkbox_enable_audiofx"; + private static final String REDUCE_REFRESH_RATE_PREF_STRING = "checkbox_reduce_refresh_rate"; + private static final String FULL_RANGE_PREF_STRING = "checkbox_full_range"; + private static final String GAMEPAD_TOUCHPAD_AS_MOUSE_PREF_STRING = "checkbox_gamepad_touchpad_as_mouse"; + private static final String GAMEPAD_MOTION_SENSORS_PREF_STRING = "checkbox_gamepad_motion_sensors"; + private static final String GAMEPAD_MOTION_FALLBACK_PREF_STRING = "checkbox_gamepad_motion_fallback"; + + //是否弹出软键盘 + private static final String CHECKBOX_ENABLE_QUIT_DIALOG = "checkbox_enable_quit_dialog"; + + //VR模式 + private static final String CHECKBOX_ENABLE_SBS = "checkbox_enable_sbs"; + //竖屏模式 + private static final String CHECKBOX_ENABLE_PORTRAIT = "checkbox_enable_portrait"; + //屏幕特殊按键 + private static final String CHECKBOX_ENABLE_KEYBOARD = "checkbox_enable_keyboard"; + + //屏幕特殊按键 震动 + private static final String CHECKBOX_ENABLE_KEYBOARD_VIBRATE = "checkbox_vibrate_keyboard"; + + //自动摇杆 + private static final String CHECKBOX_CHECKBOX_ENABLE_ANALOG_STICK_NEW="checkbox_enable_analog_stick_new"; + + //触控屏幕灵敏度 + private static final String TOUCH_SENSITIVITY="seekbar_touch_sensitivity_opacity_x"; + + static final String DEFAULT_RESOLUTION = "1280x720"; + static final String DEFAULT_FPS = "60"; + private static final boolean DEFAULT_STRETCH = false; + private static final boolean DEFAULT_SOPS = true; + private static final boolean DEFAULT_DISABLE_TOASTS = false; + private static final boolean DEFAULT_HOST_AUDIO = false; + private static final int DEFAULT_DEADZONE = 7; + private static final int DEFAULT_OPACITY = 90; + public static final String DEFAULT_LANGUAGE = "default"; + private static final boolean DEFAULT_MULTI_CONTROLLER = true; + private static final boolean DEFAULT_USB_DRIVER = true; + private static final String DEFAULT_VIDEO_FORMAT = "auto"; + + private static final boolean ONSCREEN_CONTROLLER_DEFAULT = false; + private static final boolean ONLY_L3_R3_DEFAULT = false; + private static final boolean DEFAULT_ENABLE_HDR = false; + private static final boolean DEFAULT_ENABLE_PIP = false; + private static final boolean DEFAULT_ENABLE_PERF_OVERLAY = false; + private static final boolean DEFAULT_BIND_ALL_USB = false; + private static final boolean DEFAULT_MOUSE_EMULATION = true; + private static final String DEFAULT_ANALOG_STICK_FOR_SCROLLING = "right"; + private static final boolean DEFAULT_MOUSE_NAV_BUTTONS = false; + private static final boolean DEFAULT_UNLOCK_FPS = false; + private static final boolean DEFAULT_VIBRATE_OSC = true; + private static final boolean DEFAULT_VIBRATE_FALLBACK = false; + private static final int DEFAULT_VIBRATE_FALLBACK_STRENGTH = 100; + private static final boolean DEFAULT_FLIP_FACE_BUTTONS = false; + private static final boolean DEFAULT_TOUCHSCREEN_TRACKPAD = true; + private static final String DEFAULT_AUDIO_CONFIG = "2"; // Stereo + private static final boolean DEFAULT_LATENCY_TOAST = false; + private static final String DEFAULT_FRAME_PACING = "latency"; + private static final boolean DEFAULT_ABSOLUTE_MOUSE_MODE = false; + private static final boolean DEFAULT_ENABLE_AUDIO_FX = false; + private static final boolean DEFAULT_REDUCE_REFRESH_RATE = false; + private static final boolean DEFAULT_FULL_RANGE = false; + private static final boolean DEFAULT_GAMEPAD_TOUCHPAD_AS_MOUSE = false; + private static final boolean DEFAULT_GAMEPAD_MOTION_SENSORS = true; + private static final boolean DEFAULT_GAMEPAD_MOTION_FALLBACK = false; + + public static final int FRAME_PACING_MIN_LATENCY = 0; + public static final int FRAME_PACING_BALANCED = 1; + public static final int FRAME_PACING_CAP_FPS = 2; + public static final int FRAME_PACING_MAX_SMOOTHNESS = 3; + + public static final String RES_360P = "640x360"; + public static final String RES_480P = "854x480"; + public static final String RES_720P = "1280x720"; + public static final String RES_1080P = "1920x1080"; + public static final String RES_1440P = "2560x1440"; + public static final String RES_4K = "3840x2160"; + public static final String RES_NATIVE = "Native"; + + public int width, height, fps; + public int bitrate; + public FormatOption videoFormat; + public int deadzonePercentage; + public int oscOpacity; + public int oscKeyboardOpacity; + public int oscKeyboardHeight; + public boolean stretchVideo, enableSops, playHostAudio, disableWarnings; + public String language; + public boolean smallIconMode, multiController, usbDriver, flipFaceButtons; + public boolean onscreenController; + public boolean onlyL3R3; + public boolean enableHdr; + public boolean enablePip; + public boolean enablePerfOverlay; + //简化版性能信息 + public boolean enablePerfOverlayLite; + + public boolean enablePerfOverlayLiteDialog; + + public boolean enableLatencyToast; + //软键盘 + public boolean enableQtDialog; + //竖屏模式 + public boolean enablePortrait; + //虚拟屏幕键盘按键 + public boolean enableKeyboard; + //修复JoyCon十字键 + public boolean enableJoyConFix; + + //自由摇杆啊 + public boolean enableNewAnalogStick; + + public boolean enableExDisplay; + + //串流画面顶部居中显示 + public boolean enableDisplayTopCenter; + + //触控屏幕灵敏度 + public int touchSensitivityX; + public int touchSensitivityY; + //超出边界自动回中心点 + public boolean touchSensitivityRotationAuto; + + //触控灵敏度调节范围 + public boolean touchSensitivityGlobal; + + //多点触控灵敏度调节 + public boolean enableTouchSensitivity; + + //触控板模式灵敏度 + public int touchPadSensitivity; + + public int touchPadYSensitity; + + //多点触控模式 + public boolean enableMultiTouchScreen; + + //物理光标捕获 + public boolean enableMouseLocalCursor; + + //禁用内置的特殊指令 + public boolean enableClearDefaultSpecial; + + //强制使用设备自身的震动马达 + public boolean enableDeviceRumble; + + public boolean enableKeyboardVibrate; + + public boolean enableKeyboardSquare; + + //官方虚拟按钮风格 + public boolean enableOnScreenStyleOfficial; + + //自由摇杆背景透明度 + public int senableNewAnalogStickOpacity; + + //VR模式 + public boolean enableSbs; + public boolean bindAllUsb; + public boolean mouseEmulation; + public AnalogStickForScrolling analogStickForScrolling; + public boolean mouseNavButtons; + public boolean unlockFps; + public boolean vibrateOsc; + public boolean vibrateFallbackToDevice; + public int vibrateFallbackToDeviceStrength; + public boolean touchscreenTrackpad; + public MoonBridge.AudioConfiguration audioConfiguration; + public int framePacing; + public boolean absoluteMouseMode; + public boolean enableAudioFx; + public boolean reduceRefreshRate; + public boolean fullRange; + public boolean gamepadMotionSensors; + public boolean gamepadTouchpadAsMouse; + public boolean gamepadMotionSensorsFallbackToDevice; + + public static boolean isNativeResolution(int width, int height) { + // It's not a native resolution if it matches an existing resolution option + if (width == 640 && height == 360) { + return false; + } + else if (width == 854 && height == 480) { + return false; + } + else if (width == 1280 && height == 720) { + return false; + } + else if (width == 1920 && height == 1080) { + return false; + } + else if (width == 2560 && height == 1440) { + return false; + } + else if (width == 3840 && height == 2160) { + return false; + } + + return true; + } + + // If we have a screen that has semi-square dimensions, we may want to change our behavior + // to allow any orientation and vertical+horizontal resolutions. + public static boolean isSquarishScreen(int width, int height) { + float longDim = Math.max(width, height); + float shortDim = Math.min(width, height); + + // We just put the arbitrary cutoff for a square-ish screen at 1.3 + return longDim / shortDim < 1.3f; + } + + public static boolean isSquarishScreen(Display display) { + int width, height; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + width = display.getMode().getPhysicalWidth(); + height = display.getMode().getPhysicalHeight(); + } + else { + width = display.getWidth(); + height = display.getHeight(); + } + + return isSquarishScreen(width, height); + } + + private static String convertFromLegacyResolutionString(String resString) { + if (resString.equalsIgnoreCase("360p")) { + return RES_360P; + } + else if (resString.equalsIgnoreCase("480p")) { + return RES_480P; + } + else if (resString.equalsIgnoreCase("720p")) { + return RES_720P; + } + else if (resString.equalsIgnoreCase("1080p")) { + return RES_1080P; + } + else if (resString.equalsIgnoreCase("1440p")) { + return RES_1440P; + } + else if (resString.equalsIgnoreCase("4K")) { + return RES_4K; + } + else { + // Should be unreachable + return RES_720P; + } + } + + private static int getWidthFromResolutionString(String resString) { + return Integer.parseInt(resString.split("x")[0]); + } + + private static int getHeightFromResolutionString(String resString) { + return Integer.parseInt(resString.split("x")[1]); + } + + private static String getResolutionString(int width, int height) { + switch (height) { + case 360: + return RES_360P; + case 480: + return RES_480P; + default: + case 720: + return RES_720P; + case 1080: + return RES_1080P; + case 1440: + return RES_1440P; + case 2160: + return RES_4K; + } + } + + public static int getDefaultBitrate(String resString, String fpsString) { + int width = getWidthFromResolutionString(resString); + int height = getHeightFromResolutionString(resString); + int fps = Integer.parseInt(fpsString); + + // This logic is shamelessly stolen from Moonlight Qt: + // https://github.com/moonlight-stream/moonlight-qt/blob/master/app/settings/streamingpreferences.cpp + + // Don't scale bitrate linearly beyond 60 FPS. It's definitely not a linear + // bitrate increase for frame rate once we get to values that high. + double frameRateFactor = (fps <= 60 ? fps : (Math.sqrt(fps / 60.f) * 60.f)) / 30.f; + + // TODO: Collect some empirical data to see if these defaults make sense. + // We're just using the values that the Shield used, as we have for years. + int[] pixelVals = { + 640 * 360, + 854 * 480, + 1280 * 720, + 1920 * 1080, + 2560 * 1440, + 3840 * 2160, + -1, + }; + int[] factorVals = { + 1, + 2, + 5, + 10, + 20, + 40, + -1 + }; + + // Calculate the resolution factor by linear interpolation of the resolution table + float resolutionFactor; + int pixels = width * height; + for (int i = 0; ; i++) { + if (pixels == pixelVals[i]) { + // We can bail immediately for exact matches + resolutionFactor = factorVals[i]; + break; + } + else if (pixels < pixelVals[i]) { + if (i == 0) { + // Never go below the lowest resolution entry + resolutionFactor = factorVals[i]; + } + else { + // Interpolate between the entry greater than the chosen resolution (i) and the entry less than the chosen resolution (i-1) + resolutionFactor = ((float)(pixels - pixelVals[i-1]) / (pixelVals[i] - pixelVals[i-1])) * (factorVals[i] - factorVals[i-1]) + factorVals[i-1]; + } + break; + } + else if (pixelVals[i] == -1) { + // Never go above the highest resolution entry + resolutionFactor = factorVals[i-1]; + break; + } + } + + return (int)Math.round(resolutionFactor * frameRateFactor) * 1000; + } + + public static boolean getDefaultSmallMode(Context context) { + PackageManager manager = context.getPackageManager(); + if (manager != null) { + // TVs shouldn't use small mode by default + if (manager.hasSystemFeature(PackageManager.FEATURE_TELEVISION)) { + return false; + } + + // API 21 uses LEANBACK instead of TELEVISION + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { + if (manager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)) { + return false; + } + } + } + + // Use small mode on anything smaller than a 7" tablet + return context.getResources().getConfiguration().smallestScreenWidthDp < 500; + } + + public static int getDefaultBitrate(Context context) { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + return getDefaultBitrate( + prefs.getString(RESOLUTION_PREF_STRING, DEFAULT_RESOLUTION), + prefs.getString(FPS_PREF_STRING, DEFAULT_FPS)); + } + + private static FormatOption getVideoFormatValue(Context context) { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + + String str = prefs.getString(VIDEO_FORMAT_PREF_STRING, DEFAULT_VIDEO_FORMAT); + if (str.equals("auto")) { + return FormatOption.AUTO; + } + else if (str.equals("forceav1")) { + return FormatOption.FORCE_AV1; + } + else if (str.equals("forceh265")) { + return FormatOption.FORCE_HEVC; + } + else if (str.equals("neverh265")) { + return FormatOption.FORCE_H264; + } + else { + // Should never get here + return FormatOption.AUTO; + } + } + + private static int getFramePacingValue(Context context) { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + + // Migrate legacy never drop frames option to the new location + if (prefs.contains(LEGACY_DISABLE_FRAME_DROP_PREF_STRING)) { + boolean legacyNeverDropFrames = prefs.getBoolean(LEGACY_DISABLE_FRAME_DROP_PREF_STRING, false); + prefs.edit() + .remove(LEGACY_DISABLE_FRAME_DROP_PREF_STRING) + .putString(FRAME_PACING_PREF_STRING, legacyNeverDropFrames ? "balanced" : "latency") + .apply(); + } + + String str = prefs.getString(FRAME_PACING_PREF_STRING, DEFAULT_FRAME_PACING); + if (str.equals("latency")) { + return FRAME_PACING_MIN_LATENCY; + } + else if (str.equals("balanced")) { + return FRAME_PACING_BALANCED; + } + else if (str.equals("cap-fps")) { + return FRAME_PACING_CAP_FPS; + } + else if (str.equals("smoothness")) { + return FRAME_PACING_MAX_SMOOTHNESS; + } + else { + // Should never get here + return FRAME_PACING_MIN_LATENCY; + } + } + + private static AnalogStickForScrolling getAnalogStickForScrollingValue(Context context) { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + + String str = prefs.getString(ANALOG_SCROLLING_PREF_STRING, DEFAULT_ANALOG_STICK_FOR_SCROLLING); + if (str.equals("right")) { + return AnalogStickForScrolling.RIGHT; + } + else if (str.equals("left")) { + return AnalogStickForScrolling.LEFT; + } + else { + return AnalogStickForScrolling.NONE; + } + } + + public static void resetStreamingSettings(Context context) { + // We consider resolution, FPS, bitrate, HDR, and video format as "streaming settings" here + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + prefs.edit() + .remove(BITRATE_PREF_STRING) + .remove(BITRATE_PREF_OLD_STRING) + .remove(LEGACY_RES_FPS_PREF_STRING) + .remove(RESOLUTION_PREF_STRING) + .remove(FPS_PREF_STRING) + .remove(VIDEO_FORMAT_PREF_STRING) + .remove(ENABLE_HDR_PREF_STRING) + .remove(UNLOCK_FPS_STRING) + .remove(FULL_RANGE_PREF_STRING) + .apply(); + } + + public static void completeLanguagePreferenceMigration(Context context) { + // Put our language option back to default which tells us that we've already migrated it + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + prefs.edit().putString(LANGUAGE_PREF_STRING, DEFAULT_LANGUAGE).apply(); + } + + public static boolean isShieldAtvFirmwareWithBrokenHdr() { + // This particular Shield TV firmware crashes when using HDR + // https://www.nvidia.com/en-us/geforce/forums/notifications/comment/155192/ + return Build.MANUFACTURER.equalsIgnoreCase("NVIDIA") && + Build.FINGERPRINT.contains("PPR1.180610.011/4079208_2235.1395"); + } + + public static PreferenceConfiguration readPreferences(Context context) { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + PreferenceConfiguration config = new PreferenceConfiguration(); + + // Migrate legacy preferences to the new locations + if (prefs.contains(LEGACY_ENABLE_51_SURROUND_PREF_STRING)) { + if (prefs.getBoolean(LEGACY_ENABLE_51_SURROUND_PREF_STRING, false)) { + prefs.edit() + .remove(LEGACY_ENABLE_51_SURROUND_PREF_STRING) + .putString(AUDIO_CONFIG_PREF_STRING, "51") + .apply(); + } + } + + String str = prefs.getString(LEGACY_RES_FPS_PREF_STRING, null); + if (str != null) { + if (str.equals("360p30")) { + config.width = 640; + config.height = 360; + config.fps = 30; + } + else if (str.equals("360p60")) { + config.width = 640; + config.height = 360; + config.fps = 60; + } + else if (str.equals("720p30")) { + config.width = 1280; + config.height = 720; + config.fps = 30; + } + else if (str.equals("720p60")) { + config.width = 1280; + config.height = 720; + config.fps = 60; + } + else if (str.equals("1080p30")) { + config.width = 1920; + config.height = 1080; + config.fps = 30; + } + else if (str.equals("1080p60")) { + config.width = 1920; + config.height = 1080; + config.fps = 60; + } + else if (str.equals("4K30")) { + config.width = 3840; + config.height = 2160; + config.fps = 30; + } + else if (str.equals("4K60")) { + config.width = 3840; + config.height = 2160; + config.fps = 60; + } + else { + // Should never get here + config.width = 1280; + config.height = 720; + config.fps = 60; + } + + prefs.edit() + .remove(LEGACY_RES_FPS_PREF_STRING) + .putString(RESOLUTION_PREF_STRING, getResolutionString(config.width, config.height)) + .putString(FPS_PREF_STRING, ""+config.fps) + .apply(); + } + else { + // Use the new preference location + String resStr = prefs.getString(RESOLUTION_PREF_STRING, PreferenceConfiguration.DEFAULT_RESOLUTION); + + // Convert legacy resolution strings to the new style + if (!resStr.contains("x")) { + resStr = PreferenceConfiguration.convertFromLegacyResolutionString(resStr); + prefs.edit().putString(RESOLUTION_PREF_STRING, resStr).apply(); + } + + config.width = PreferenceConfiguration.getWidthFromResolutionString(resStr); + config.height = PreferenceConfiguration.getHeightFromResolutionString(resStr); + config.fps = Integer.parseInt(prefs.getString(FPS_PREF_STRING, PreferenceConfiguration.DEFAULT_FPS)); + } + + if (!prefs.contains(SMALL_ICONS_PREF_STRING)) { + // We need to write small icon mode's default to disk for the settings page to display + // the current state of the option properly + prefs.edit().putBoolean(SMALL_ICONS_PREF_STRING, getDefaultSmallMode(context)).apply(); + } + + if (!prefs.contains(GAMEPAD_MOTION_SENSORS_PREF_STRING) && Build.VERSION.SDK_INT == Build.VERSION_CODES.S) { + // Android 12 has a nasty bug that causes crashes when the app touches the InputDevice's + // associated InputDeviceSensorManager (just calling getSensorManager() is enough). + // As a workaround, we will override the default value for the gamepad motion sensor + // option to disabled on Android 12 to reduce the impact of this bug. + // https://cs.android.com/android/_/android/platform/frameworks/base/+/8970010a5e9f3dc5c069f56b4147552accfcbbeb + prefs.edit().putBoolean(GAMEPAD_MOTION_SENSORS_PREF_STRING, false).apply(); + } + + // This must happen after the preferences migration to ensure the preferences are populated + config.bitrate = prefs.getInt(BITRATE_PREF_STRING, prefs.getInt(BITRATE_PREF_OLD_STRING, 0) * 1000); + if (config.bitrate == 0) { + config.bitrate = getDefaultBitrate(context); + } + + String audioConfig = prefs.getString(AUDIO_CONFIG_PREF_STRING, DEFAULT_AUDIO_CONFIG); + if (audioConfig.equals("71")) { + config.audioConfiguration = MoonBridge.AUDIO_CONFIGURATION_71_SURROUND; + } + else if (audioConfig.equals("51")) { + config.audioConfiguration = MoonBridge.AUDIO_CONFIGURATION_51_SURROUND; + } + else /* if (audioConfig.equals("2")) */ { + config.audioConfiguration = MoonBridge.AUDIO_CONFIGURATION_STEREO; + } + + config.videoFormat = getVideoFormatValue(context); + config.framePacing = getFramePacingValue(context); + + config.analogStickForScrolling = getAnalogStickForScrollingValue(context); + + config.deadzonePercentage = prefs.getInt(DEADZONE_PREF_STRING, DEFAULT_DEADZONE); + + config.oscOpacity = prefs.getInt(OSC_OPACITY_PREF_STRING, DEFAULT_OPACITY); + + config.language = prefs.getString(LANGUAGE_PREF_STRING, DEFAULT_LANGUAGE); + + // Checkbox preferences + config.disableWarnings = prefs.getBoolean(DISABLE_TOASTS_PREF_STRING, DEFAULT_DISABLE_TOASTS); + config.enableSops = prefs.getBoolean(SOPS_PREF_STRING, DEFAULT_SOPS); + config.stretchVideo = prefs.getBoolean(STRETCH_PREF_STRING, DEFAULT_STRETCH); + config.playHostAudio = prefs.getBoolean(HOST_AUDIO_PREF_STRING, DEFAULT_HOST_AUDIO); + config.smallIconMode = prefs.getBoolean(SMALL_ICONS_PREF_STRING, getDefaultSmallMode(context)); + config.multiController = prefs.getBoolean(MULTI_CONTROLLER_PREF_STRING, DEFAULT_MULTI_CONTROLLER); + config.usbDriver = prefs.getBoolean(USB_DRIVER_PREF_SRING, DEFAULT_USB_DRIVER); + config.onscreenController = prefs.getBoolean(ONSCREEN_CONTROLLER_PREF_STRING, ONSCREEN_CONTROLLER_DEFAULT); + config.onlyL3R3 = prefs.getBoolean(ONLY_L3_R3_PREF_STRING, ONLY_L3_R3_DEFAULT); + config.enableHdr = prefs.getBoolean(ENABLE_HDR_PREF_STRING, DEFAULT_ENABLE_HDR) && !isShieldAtvFirmwareWithBrokenHdr(); + config.enablePip = prefs.getBoolean(ENABLE_PIP_PREF_STRING, DEFAULT_ENABLE_PIP); + config.enablePerfOverlay = prefs.getBoolean(ENABLE_PERF_OVERLAY_STRING, DEFAULT_ENABLE_PERF_OVERLAY); + config.enablePerfOverlayLite=prefs.getBoolean("checkbox_enable_perf_overlay_lite",DEFAULT_ENABLE_PERF_OVERLAY); + config.bindAllUsb = prefs.getBoolean(BIND_ALL_USB_STRING, DEFAULT_BIND_ALL_USB); + config.mouseEmulation = prefs.getBoolean(MOUSE_EMULATION_STRING, DEFAULT_MOUSE_EMULATION); + config.mouseNavButtons = prefs.getBoolean(MOUSE_NAV_BUTTONS_STRING, DEFAULT_MOUSE_NAV_BUTTONS); + config.unlockFps = prefs.getBoolean(UNLOCK_FPS_STRING, DEFAULT_UNLOCK_FPS); + config.vibrateOsc = prefs.getBoolean(VIBRATE_OSC_PREF_STRING, DEFAULT_VIBRATE_OSC); + config.vibrateFallbackToDevice = prefs.getBoolean(VIBRATE_FALLBACK_PREF_STRING, DEFAULT_VIBRATE_FALLBACK); + config.vibrateFallbackToDeviceStrength = prefs.getInt(VIBRATE_FALLBACK_STRENGTH_PREF_STRING, DEFAULT_VIBRATE_FALLBACK_STRENGTH); + config.flipFaceButtons = prefs.getBoolean(FLIP_FACE_BUTTONS_PREF_STRING, DEFAULT_FLIP_FACE_BUTTONS); +// config.touchscreenTrackpad = prefs.getBoolean(TOUCHSCREEN_TRACKPAD_PREF_STRING, DEFAULT_TOUCHSCREEN_TRACKPAD); + config.enableLatencyToast = prefs.getBoolean(LATENCY_TOAST_PREF_STRING, DEFAULT_LATENCY_TOAST); + //软键盘 + config.enableQtDialog = prefs.getBoolean(CHECKBOX_ENABLE_QUIT_DIALOG,false); + config.enableSbs = prefs.getBoolean(CHECKBOX_ENABLE_SBS,false); + config.enablePortrait = prefs.getBoolean(CHECKBOX_ENABLE_PORTRAIT,false); + + config.enableKeyboard = prefs.getBoolean(CHECKBOX_ENABLE_KEYBOARD,false); + + config.enableKeyboardVibrate=prefs.getBoolean(CHECKBOX_ENABLE_KEYBOARD_VIBRATE,false); + //兼容joycon手柄 + config.enableJoyConFix=prefs.getBoolean("checkbox_enable_joyconfix",false); + //全键盘透明度 + config.oscKeyboardOpacity=prefs.getInt("seekbar_keyboard_axi_opacity",DEFAULT_OPACITY); + + config.enableOnScreenStyleOfficial=prefs.getBoolean("checkbox_onscreen_style_official",false); + + config.senableNewAnalogStickOpacity=prefs.getInt("seekbar_osc_free_analog_stick_opacity",20); + + config.oscKeyboardHeight=prefs.getInt("seekbar_keyboard_axi_height",200); + + config.enableNewAnalogStick=prefs.getBoolean(CHECKBOX_CHECKBOX_ENABLE_ANALOG_STICK_NEW,false); + + config.enableExDisplay=prefs.getBoolean("checkbox_enable_exdisplay",false); + + config.enableDisplayTopCenter=prefs.getBoolean("checkbox_enable_view_top_center",false); + + config.touchSensitivityX =prefs.getInt(TOUCH_SENSITIVITY,100); + + config.touchSensitivityY=prefs.getInt("seekbar_touch_sensitivity_opacity_y",100); + + config.touchSensitivityRotationAuto=prefs.getBoolean("checkbox_enable_touch_sensitivity_rotation_auto",true); + + config.touchSensitivityGlobal=prefs.getBoolean("checkbox_enable_global_touch_sensitivity",false); + + config.enableTouchSensitivity=prefs.getBoolean("checkbox_enable_touch_sensitivity",false); + + config.enableMouseLocalCursor=prefs.getBoolean("checkbox_mouse_local_cursor",false); + + config.enablePerfOverlayLiteDialog=prefs.getBoolean("checkbox_enable_perf_overlay_lite_dialog",false); + + config.enableClearDefaultSpecial=prefs.getBoolean("checkbox_enable_clear_default_special_button", false); + + config.enableDeviceRumble=prefs.getBoolean("checkbox_enable_device_rumble", false); + + config.enableKeyboardSquare=prefs.getBoolean("checkbox_enable_keyboard_square",false); + + config.touchPadSensitivity=prefs.getInt("seekbar_touchpad_sensitivity_opacity",100); + + config.touchPadYSensitity=prefs.getInt("seekbar_touchpad_sensitivity_y_opacity",100); + + config.absoluteMouseMode = prefs.getBoolean(ABSOLUTE_MOUSE_MODE_PREF_STRING, DEFAULT_ABSOLUTE_MOUSE_MODE); + config.enableAudioFx = prefs.getBoolean(ENABLE_AUDIO_FX_PREF_STRING, DEFAULT_ENABLE_AUDIO_FX); + config.reduceRefreshRate = prefs.getBoolean(REDUCE_REFRESH_RATE_PREF_STRING, DEFAULT_REDUCE_REFRESH_RATE); + config.fullRange = prefs.getBoolean(FULL_RANGE_PREF_STRING, DEFAULT_FULL_RANGE); + config.gamepadTouchpadAsMouse = prefs.getBoolean(GAMEPAD_TOUCHPAD_AS_MOUSE_PREF_STRING, DEFAULT_GAMEPAD_TOUCHPAD_AS_MOUSE); + config.gamepadMotionSensors = prefs.getBoolean(GAMEPAD_MOTION_SENSORS_PREF_STRING, DEFAULT_GAMEPAD_MOTION_SENSORS); + config.gamepadMotionSensorsFallbackToDevice = prefs.getBoolean(GAMEPAD_MOTION_FALLBACK_PREF_STRING, DEFAULT_GAMEPAD_MOTION_FALLBACK); + + return config; + } +} diff --git a/app/src/main/java/com/limelight/preferences/SeekBarPreference.java b/app/src/main/java/com/limelight/preferences/SeekBarPreference.java old mode 100644 new mode 100755 index 360ef29c0a..acb4756ca7 --- a/app/src/main/java/com/limelight/preferences/SeekBarPreference.java +++ b/app/src/main/java/com/limelight/preferences/SeekBarPreference.java @@ -1,193 +1,193 @@ -package com.limelight.preferences; - -import android.app.AlertDialog; -import android.content.Context; -import android.os.Bundle; -import android.preference.DialogPreference; -import android.util.AttributeSet; -import android.util.Log; -import android.view.Gravity; -import android.view.View; -import android.view.View.OnClickListener; -import android.widget.Button; -import android.widget.LinearLayout; -import android.widget.SeekBar; -import android.widget.TextView; - -import java.util.Locale; - -// Based on a Stack Overflow example: http://stackoverflow.com/questions/1974193/slider-on-my-preferencescreen -public class SeekBarPreference extends DialogPreference -{ - private static final String ANDROID_SCHEMA_URL = "http://schemas.android.com/apk/res/android"; - private static final String SEEKBAR_SCHEMA_URL = "http://schemas.moonlight-stream.com/apk/res/seekbar"; - - private SeekBar seekBar; - private TextView valueText; - private final Context context; - - private final String dialogMessage; - private final String suffix; - private final int defaultValue; - private final int maxValue; - private final int minValue; - private final int stepSize; - private final int keyStepSize; - private final int divisor; - private int currentValue; - - public SeekBarPreference(Context context, AttributeSet attrs) { - super(context, attrs); - this.context = context; - - // Read the message from XML - int dialogMessageId = attrs.getAttributeResourceValue(ANDROID_SCHEMA_URL, "dialogMessage", 0); - if (dialogMessageId == 0) { - dialogMessage = attrs.getAttributeValue(ANDROID_SCHEMA_URL, "dialogMessage"); - } - else { - dialogMessage = context.getString(dialogMessageId); - } - - // Get the suffix for the number displayed in the dialog - int suffixId = attrs.getAttributeResourceValue(ANDROID_SCHEMA_URL, "text", 0); - if (suffixId == 0) { - suffix = attrs.getAttributeValue(ANDROID_SCHEMA_URL, "text"); - } - else { - suffix = context.getString(suffixId); - } - - // Get default, min, and max seekbar values - defaultValue = attrs.getAttributeIntValue(ANDROID_SCHEMA_URL, "defaultValue", PreferenceConfiguration.getDefaultBitrate(context)); - maxValue = attrs.getAttributeIntValue(ANDROID_SCHEMA_URL, "max", 100); - minValue = attrs.getAttributeIntValue(SEEKBAR_SCHEMA_URL, "min", 1); - stepSize = attrs.getAttributeIntValue(SEEKBAR_SCHEMA_URL, "step", 1); - divisor = attrs.getAttributeIntValue(SEEKBAR_SCHEMA_URL, "divisor", 1); - keyStepSize = attrs.getAttributeIntValue(SEEKBAR_SCHEMA_URL, "keyStep", 0); - } - - @Override - protected View onCreateDialogView() { - - LinearLayout.LayoutParams params; - LinearLayout layout = new LinearLayout(context); - layout.setOrientation(LinearLayout.VERTICAL); - layout.setPadding(6, 6, 6, 6); - - TextView splashText = new TextView(context); - splashText.setPadding(30, 10, 30, 10); - if (dialogMessage != null) { - splashText.setText(dialogMessage); - } - layout.addView(splashText); - - valueText = new TextView(context); - valueText.setGravity(Gravity.CENTER_HORIZONTAL); - valueText.setTextSize(32); - // Default text for value; hides bug where OnSeekBarChangeListener isn't called when opacity is 0% - valueText.setText("0%"); - params = new LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.WRAP_CONTENT); - layout.addView(valueText, params); - - seekBar = new SeekBar(context); - seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { - @Override - public void onProgressChanged(SeekBar seekBar, int value, boolean b) { - if (value < minValue) { - seekBar.setProgress(minValue); - return; - } - - int roundedValue = ((value + (stepSize - 1))/stepSize)*stepSize; - if (roundedValue != value) { - seekBar.setProgress(roundedValue); - return; - } - - String t; - if (divisor != 1) { - float floatValue = roundedValue / (float)divisor; - t = String.format((Locale)null, "%.1f", floatValue); - } - else { - t = String.valueOf(value); - } - valueText.setText(suffix == null ? t : t.concat(suffix.length() > 1 ? " "+suffix : suffix)); - } - - @Override - public void onStartTrackingTouch(SeekBar seekBar) {} - - @Override - public void onStopTrackingTouch(SeekBar seekBar) {} - }); - - layout.addView(seekBar, new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)); - - if (shouldPersist()) { - currentValue = getPersistedInt(defaultValue); - } - - seekBar.setMax(maxValue); - if (keyStepSize != 0) { - seekBar.setKeyProgressIncrement(keyStepSize); - } - seekBar.setProgress(currentValue); - - return layout; - } - - @Override - protected void onBindDialogView(View v) { - super.onBindDialogView(v); - seekBar.setMax(maxValue); - if (keyStepSize != 0) { - seekBar.setKeyProgressIncrement(keyStepSize); - } - seekBar.setProgress(currentValue); - } - - @Override - protected void onSetInitialValue(boolean restore, Object defaultValue) - { - super.onSetInitialValue(restore, defaultValue); - if (restore) { - currentValue = shouldPersist() ? getPersistedInt(this.defaultValue) : 0; - } - else { - currentValue = (Integer) defaultValue; - } - } - - public void setProgress(int progress) { - this.currentValue = progress; - if (seekBar != null) { - seekBar.setProgress(progress); - } - } - public int getProgress() { - return currentValue; - } - - @Override - public void showDialog(Bundle state) { - super.showDialog(state); - - Button positiveButton = ((AlertDialog) getDialog()).getButton(AlertDialog.BUTTON_POSITIVE); - positiveButton.setOnClickListener(new OnClickListener() { - @Override - public void onClick(View view) { - if (shouldPersist()) { - currentValue = seekBar.getProgress(); - persistInt(seekBar.getProgress()); - callChangeListener(seekBar.getProgress()); - } - - getDialog().dismiss(); - } - }); - } -} +package com.limelight.preferences; + +import android.app.AlertDialog; +import android.content.Context; +import android.os.Bundle; +import android.preference.DialogPreference; +import android.util.AttributeSet; +import android.util.Log; +import android.view.Gravity; +import android.view.View; +import android.view.View.OnClickListener; +import android.widget.Button; +import android.widget.LinearLayout; +import android.widget.SeekBar; +import android.widget.TextView; + +import java.util.Locale; + +// Based on a Stack Overflow example: http://stackoverflow.com/questions/1974193/slider-on-my-preferencescreen +public class SeekBarPreference extends DialogPreference +{ + private static final String ANDROID_SCHEMA_URL = "http://schemas.android.com/apk/res/android"; + private static final String SEEKBAR_SCHEMA_URL = "http://schemas.moonlight-stream.com/apk/res/seekbar"; + + private SeekBar seekBar; + private TextView valueText; + private final Context context; + + private final String dialogMessage; + private final String suffix; + private final int defaultValue; + private final int maxValue; + private final int minValue; + private final int stepSize; + private final int keyStepSize; + private final int divisor; + private int currentValue; + + public SeekBarPreference(Context context, AttributeSet attrs) { + super(context, attrs); + this.context = context; + + // Read the message from XML + int dialogMessageId = attrs.getAttributeResourceValue(ANDROID_SCHEMA_URL, "dialogMessage", 0); + if (dialogMessageId == 0) { + dialogMessage = attrs.getAttributeValue(ANDROID_SCHEMA_URL, "dialogMessage"); + } + else { + dialogMessage = context.getString(dialogMessageId); + } + + // Get the suffix for the number displayed in the dialog + int suffixId = attrs.getAttributeResourceValue(ANDROID_SCHEMA_URL, "text", 0); + if (suffixId == 0) { + suffix = attrs.getAttributeValue(ANDROID_SCHEMA_URL, "text"); + } + else { + suffix = context.getString(suffixId); + } + + // Get default, min, and max seekbar values + defaultValue = attrs.getAttributeIntValue(ANDROID_SCHEMA_URL, "defaultValue", PreferenceConfiguration.getDefaultBitrate(context)); + maxValue = attrs.getAttributeIntValue(ANDROID_SCHEMA_URL, "max", 100); + minValue = attrs.getAttributeIntValue(SEEKBAR_SCHEMA_URL, "min", 1); + stepSize = attrs.getAttributeIntValue(SEEKBAR_SCHEMA_URL, "step", 1); + divisor = attrs.getAttributeIntValue(SEEKBAR_SCHEMA_URL, "divisor", 1); + keyStepSize = attrs.getAttributeIntValue(SEEKBAR_SCHEMA_URL, "keyStep", 0); + } + + @Override + protected View onCreateDialogView() { + + LinearLayout.LayoutParams params; + LinearLayout layout = new LinearLayout(context); + layout.setOrientation(LinearLayout.VERTICAL); + layout.setPadding(6, 6, 6, 6); + + TextView splashText = new TextView(context); + splashText.setPadding(30, 10, 30, 10); + if (dialogMessage != null) { + splashText.setText(dialogMessage); + } + layout.addView(splashText); + + valueText = new TextView(context); + valueText.setGravity(Gravity.CENTER_HORIZONTAL); + valueText.setTextSize(32); + // Default text for value; hides bug where OnSeekBarChangeListener isn't called when opacity is 0% + valueText.setText("0%"); + params = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT); + layout.addView(valueText, params); + + seekBar = new SeekBar(context); + seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int value, boolean b) { + if (value < minValue) { + seekBar.setProgress(minValue); + return; + } + + int roundedValue = ((value + (stepSize - 1))/stepSize)*stepSize; + if (roundedValue != value) { + seekBar.setProgress(roundedValue); + return; + } + + String t; + if (divisor != 1) { + float floatValue = roundedValue / (float)divisor; + t = String.format((Locale)null, "%.1f", floatValue); + } + else { + t = String.valueOf(value); + } + valueText.setText(suffix == null ? t : t.concat(suffix.length() > 1 ? " "+suffix : suffix)); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) {} + + @Override + public void onStopTrackingTouch(SeekBar seekBar) {} + }); + + layout.addView(seekBar, new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)); + + if (shouldPersist()) { + currentValue = getPersistedInt(defaultValue); + } + + seekBar.setMax(maxValue); + if (keyStepSize != 0) { + seekBar.setKeyProgressIncrement(keyStepSize); + } + seekBar.setProgress(currentValue); + + return layout; + } + + @Override + protected void onBindDialogView(View v) { + super.onBindDialogView(v); + seekBar.setMax(maxValue); + if (keyStepSize != 0) { + seekBar.setKeyProgressIncrement(keyStepSize); + } + seekBar.setProgress(currentValue); + } + + @Override + protected void onSetInitialValue(boolean restore, Object defaultValue) + { + super.onSetInitialValue(restore, defaultValue); + if (restore) { + currentValue = shouldPersist() ? getPersistedInt(this.defaultValue) : 0; + } + else { + currentValue = (Integer) defaultValue; + } + } + + public void setProgress(int progress) { + this.currentValue = progress; + if (seekBar != null) { + seekBar.setProgress(progress); + } + } + public int getProgress() { + return currentValue; + } + + @Override + public void showDialog(Bundle state) { + super.showDialog(state); + + Button positiveButton = ((AlertDialog) getDialog()).getButton(AlertDialog.BUTTON_POSITIVE); + positiveButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View view) { + if (shouldPersist()) { + currentValue = seekBar.getProgress(); + persistInt(seekBar.getProgress()); + callChangeListener(seekBar.getProgress()); + } + + getDialog().dismiss(); + } + }); + } +} diff --git a/app/src/main/java/com/limelight/preferences/SmallIconCheckboxPreference.java b/app/src/main/java/com/limelight/preferences/SmallIconCheckboxPreference.java old mode 100644 new mode 100755 index c216b74909..8006b7e93d --- a/app/src/main/java/com/limelight/preferences/SmallIconCheckboxPreference.java +++ b/app/src/main/java/com/limelight/preferences/SmallIconCheckboxPreference.java @@ -1,21 +1,21 @@ -package com.limelight.preferences; - -import android.content.Context; -import android.content.res.TypedArray; -import android.preference.CheckBoxPreference; -import android.util.AttributeSet; - -public class SmallIconCheckboxPreference extends CheckBoxPreference { - public SmallIconCheckboxPreference(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - } - - public SmallIconCheckboxPreference(Context context, AttributeSet attrs) { - super(context, attrs); - } - - @Override - protected Object onGetDefaultValue(TypedArray a, int index) { - return PreferenceConfiguration.getDefaultSmallMode(getContext()); - } -} +package com.limelight.preferences; + +import android.content.Context; +import android.content.res.TypedArray; +import android.preference.CheckBoxPreference; +import android.util.AttributeSet; + +public class SmallIconCheckboxPreference extends CheckBoxPreference { + public SmallIconCheckboxPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public SmallIconCheckboxPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected Object onGetDefaultValue(TypedArray a, int index) { + return PreferenceConfiguration.getDefaultSmallMode(getContext()); + } +} diff --git a/app/src/main/java/com/limelight/preferences/StreamSettings.java b/app/src/main/java/com/limelight/preferences/StreamSettings.java old mode 100644 new mode 100755 index 7070104168..7341372abe --- a/app/src/main/java/com/limelight/preferences/StreamSettings.java +++ b/app/src/main/java/com/limelight/preferences/StreamSettings.java @@ -1,672 +1,888 @@ -package com.limelight.preferences; - -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.pm.PackageManager; -import android.content.res.Configuration; -import android.media.MediaCodecInfo; -import android.os.Build; -import android.os.Bundle; -import android.app.Activity; -import android.os.Handler; -import android.os.Vibrator; -import android.preference.CheckBoxPreference; -import android.preference.ListPreference; -import android.preference.Preference; -import android.preference.PreferenceCategory; -import android.preference.PreferenceFragment; -import android.preference.PreferenceManager; -import android.preference.PreferenceScreen; -import android.util.DisplayMetrics; -import android.util.Range; -import android.view.Display; -import android.view.DisplayCutout; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.view.WindowInsets; - -import com.limelight.LimeLog; -import com.limelight.PcView; -import com.limelight.R; -import com.limelight.binding.video.MediaCodecHelper; -import com.limelight.utils.Dialog; -import com.limelight.utils.UiHelper; - -import java.lang.reflect.Method; -import java.util.Arrays; - -public class StreamSettings extends Activity { - private PreferenceConfiguration previousPrefs; - private int previousDisplayPixelCount; - - // HACK for Android 9 - static DisplayCutout displayCutoutP; - - void reloadSettings() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - Display.Mode mode = getWindowManager().getDefaultDisplay().getMode(); - previousDisplayPixelCount = mode.getPhysicalWidth() * mode.getPhysicalHeight(); - } - getFragmentManager().beginTransaction().replace( - R.id.stream_settings, new SettingsFragment() - ).commitAllowingStateLoss(); - } - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - previousPrefs = PreferenceConfiguration.readPreferences(this); - - UiHelper.setLocale(this); - - setContentView(R.layout.activity_stream_settings); - - UiHelper.notifyNewRootView(this); - } - - @Override - public void onAttachedToWindow() { - super.onAttachedToWindow(); - - // We have to use this hack on Android 9 because we don't have Display.getCutout() - // which was added in Android 10. - if (Build.VERSION.SDK_INT == Build.VERSION_CODES.P) { - // Insets can be null when the activity is recreated on screen rotation - // https://stackoverflow.com/questions/61241255/windowinsets-getdisplaycutout-is-null-everywhere-except-within-onattachedtowindo - WindowInsets insets = getWindow().getDecorView().getRootWindowInsets(); - if (insets != null) { - displayCutoutP = insets.getDisplayCutout(); - } - } - - reloadSettings(); - } - - @Override - public void onConfigurationChanged(Configuration newConfig) { - super.onConfigurationChanged(newConfig); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - Display.Mode mode = getWindowManager().getDefaultDisplay().getMode(); - - // If the display's physical pixel count has changed, we consider that it's a new display - // and we should reload our settings (which include display-dependent values). - // - // NB: We aren't using displayId here because that stays the same (DEFAULT_DISPLAY) when - // switching between screens on a foldable device. - if (mode.getPhysicalWidth() * mode.getPhysicalHeight() != previousDisplayPixelCount) { - reloadSettings(); - } - } - } - - @Override - // NOTE: This will NOT be called on Android 13+ with android:enableOnBackInvokedCallback="true" - public void onBackPressed() { - finish(); - - // Language changes are handled via configuration changes in Android 13+, - // so manual activity relaunching is no longer required. - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { - PreferenceConfiguration newPrefs = PreferenceConfiguration.readPreferences(this); - if (!newPrefs.language.equals(previousPrefs.language)) { - // Restart the PC view to apply UI changes - Intent intent = new Intent(this, PcView.class); - intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_NEW_TASK); - startActivity(intent, null); - } - } - } - - public static class SettingsFragment extends PreferenceFragment { - private int nativeResolutionStartIndex = Integer.MAX_VALUE; - private boolean nativeFramerateShown = false; - - private void setValue(String preferenceKey, String value) { - ListPreference pref = (ListPreference) findPreference(preferenceKey); - - pref.setValue(value); - } - - private void appendPreferenceEntry(ListPreference pref, String newEntryName, String newEntryValue) { - CharSequence[] newEntries = Arrays.copyOf(pref.getEntries(), pref.getEntries().length + 1); - CharSequence[] newValues = Arrays.copyOf(pref.getEntryValues(), pref.getEntryValues().length + 1); - - // Add the new option - newEntries[newEntries.length - 1] = newEntryName; - newValues[newValues.length - 1] = newEntryValue; - - pref.setEntries(newEntries); - pref.setEntryValues(newValues); - } - - private void addNativeResolutionEntry(int nativeWidth, int nativeHeight, boolean insetsRemoved, boolean portrait) { - ListPreference pref = (ListPreference) findPreference(PreferenceConfiguration.RESOLUTION_PREF_STRING); - - String newName; - - if (insetsRemoved) { - newName = getResources().getString(R.string.resolution_prefix_native_fullscreen); - } - else { - newName = getResources().getString(R.string.resolution_prefix_native); - } - - if (PreferenceConfiguration.isSquarishScreen(nativeWidth, nativeHeight)) { - if (portrait) { - newName += " " + getResources().getString(R.string.resolution_prefix_native_portrait); - } - else { - newName += " " + getResources().getString(R.string.resolution_prefix_native_landscape); - } - } - - newName += " ("+nativeWidth+"x"+nativeHeight+")"; - - String newValue = nativeWidth+"x"+nativeHeight; - - // Check if the native resolution is already present - for (CharSequence value : pref.getEntryValues()) { - if (newValue.equals(value.toString())) { - // It is present in the default list, so don't add it again - return; - } - } - - if (pref.getEntryValues().length < nativeResolutionStartIndex) { - nativeResolutionStartIndex = pref.getEntryValues().length; - } - appendPreferenceEntry(pref, newName, newValue); - } - - private void addNativeResolutionEntries(int nativeWidth, int nativeHeight, boolean insetsRemoved) { - if (PreferenceConfiguration.isSquarishScreen(nativeWidth, nativeHeight)) { - addNativeResolutionEntry(nativeHeight, nativeWidth, insetsRemoved, true); - } - addNativeResolutionEntry(nativeWidth, nativeHeight, insetsRemoved, false); - } - - private void addNativeFrameRateEntry(float framerate) { - int frameRateRounded = Math.round(framerate); - if (frameRateRounded == 0) { - return; - } - - ListPreference pref = (ListPreference) findPreference(PreferenceConfiguration.FPS_PREF_STRING); - String fpsValue = Integer.toString(frameRateRounded); - String fpsName = getResources().getString(R.string.resolution_prefix_native) + - " (" + fpsValue + " " + getResources().getString(R.string.fps_suffix_fps) + ")"; - - // Check if the native frame rate is already present - for (CharSequence value : pref.getEntryValues()) { - if (fpsValue.equals(value.toString())) { - // It is present in the default list, so don't add it again - nativeFramerateShown = false; - return; - } - } - - appendPreferenceEntry(pref, fpsName, fpsValue); - nativeFramerateShown = true; - } - - private void removeValue(String preferenceKey, String value, Runnable onMatched) { - int matchingCount = 0; - - ListPreference pref = (ListPreference) findPreference(preferenceKey); - - // Count the number of matching entries we'll be removing - for (CharSequence seq : pref.getEntryValues()) { - if (seq.toString().equalsIgnoreCase(value)) { - matchingCount++; - } - } - - // Create the new arrays - CharSequence[] entries = new CharSequence[pref.getEntries().length-matchingCount]; - CharSequence[] entryValues = new CharSequence[pref.getEntryValues().length-matchingCount]; - int outIndex = 0; - for (int i = 0; i < pref.getEntryValues().length; i++) { - if (pref.getEntryValues()[i].toString().equalsIgnoreCase(value)) { - // Skip matching values - continue; - } - - entries[outIndex] = pref.getEntries()[i]; - entryValues[outIndex] = pref.getEntryValues()[i]; - outIndex++; - } - - if (pref.getValue().equalsIgnoreCase(value)) { - onMatched.run(); - } - - // Update the preference with the new list - pref.setEntries(entries); - pref.setEntryValues(entryValues); - } - - private void resetBitrateToDefault(SharedPreferences prefs, String res, String fps) { - if (res == null) { - res = prefs.getString(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.DEFAULT_RESOLUTION); - } - if (fps == null) { - fps = prefs.getString(PreferenceConfiguration.FPS_PREF_STRING, PreferenceConfiguration.DEFAULT_FPS); - } - - prefs.edit() - .putInt(PreferenceConfiguration.BITRATE_PREF_STRING, - PreferenceConfiguration.getDefaultBitrate(res, fps)) - .apply(); - } - - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View view = super.onCreateView(inflater, container, savedInstanceState); - UiHelper.applyStatusBarPadding(view); - return view; - } - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - addPreferencesFromResource(R.xml.preferences); - PreferenceScreen screen = getPreferenceScreen(); - - // hide on-screen controls category on non touch screen devices - if (!getActivity().getPackageManager().hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN)) { - PreferenceCategory category = - (PreferenceCategory) findPreference("category_onscreen_controls"); - screen.removePreference(category); - } - - // Hide remote desktop mouse mode on pre-Oreo (which doesn't have pointer capture) - // and NVIDIA SHIELD devices (which support raw mouse input in pointer capture mode) - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || - getActivity().getPackageManager().hasSystemFeature("com.nvidia.feature.shield")) { - PreferenceCategory category = - (PreferenceCategory) findPreference("category_input_settings"); - category.removePreference(findPreference("checkbox_absolute_mouse_mode")); - } - - // Hide gamepad motion sensor option when running on OSes before Android 12. - // Support for motion, LED, battery, and other extensions were introduced in S. - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { - PreferenceCategory category = - (PreferenceCategory) findPreference("category_gamepad_settings"); - category.removePreference(findPreference("checkbox_gamepad_motion_sensors")); - } - - // Hide gamepad motion sensor fallback option if the device has no gyro or accelerometer - if (!getActivity().getPackageManager().hasSystemFeature(PackageManager.FEATURE_SENSOR_ACCELEROMETER) && - !getActivity().getPackageManager().hasSystemFeature(PackageManager.FEATURE_SENSOR_GYROSCOPE)) { - PreferenceCategory category = - (PreferenceCategory) findPreference("category_gamepad_settings"); - category.removePreference(findPreference("checkbox_gamepad_motion_fallback")); - } - - // Hide USB driver options on devices without USB host support - if (!getActivity().getPackageManager().hasSystemFeature(PackageManager.FEATURE_USB_HOST)) { - PreferenceCategory category = - (PreferenceCategory) findPreference("category_gamepad_settings"); - category.removePreference(findPreference("checkbox_usb_bind_all")); - category.removePreference(findPreference("checkbox_usb_driver")); - } - - // Remove PiP mode on devices pre-Oreo, where the feature is not available (some low RAM devices), - // and on Fire OS where it violates the Amazon App Store guidelines for some reason. - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || - !getActivity().getPackageManager().hasSystemFeature("android.software.picture_in_picture") || - getActivity().getPackageManager().hasSystemFeature("com.amazon.software.fireos")) { - PreferenceCategory category = - (PreferenceCategory) findPreference("category_ui_settings"); - category.removePreference(findPreference("checkbox_enable_pip")); - } - - // Fire TV apps are not allowed to use WebViews or browsers, so hide the Help category - /*if (getActivity().getPackageManager().hasSystemFeature("amazon.hardware.fire_tv")) { - PreferenceCategory category = - (PreferenceCategory) findPreference("category_help"); - screen.removePreference(category); - }*/ - PreferenceCategory category_gamepad_settings = - (PreferenceCategory) findPreference("category_gamepad_settings"); - // Remove the vibration options if the device can't vibrate - if (!((Vibrator)getActivity().getSystemService(Context.VIBRATOR_SERVICE)).hasVibrator()) { - category_gamepad_settings.removePreference(findPreference("checkbox_vibrate_fallback")); - category_gamepad_settings.removePreference(findPreference("seekbar_vibrate_fallback_strength")); - // The entire OSC category may have already been removed by the touchscreen check above - PreferenceCategory category = (PreferenceCategory) findPreference("category_onscreen_controls"); - if (category != null) { - category.removePreference(findPreference("checkbox_vibrate_osc")); - } - } - else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || - !((Vibrator)getActivity().getSystemService(Context.VIBRATOR_SERVICE)).hasAmplitudeControl() ) { - // Remove the vibration strength selector of the device doesn't have amplitude control - category_gamepad_settings.removePreference(findPreference("seekbar_vibrate_fallback_strength")); - } - - Display display = getActivity().getWindowManager().getDefaultDisplay(); - float maxSupportedFps = display.getRefreshRate(); - - // Hide non-supported resolution/FPS combinations - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - int maxSupportedResW = 0; - - // Add a native resolution with any insets included for users that don't want content - // behind the notch of their display - boolean hasInsets = false; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - DisplayCutout cutout; - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - // Use the much nicer Display.getCutout() API on Android 10+ - cutout = display.getCutout(); - } - else { - // Android 9 only - cutout = displayCutoutP; - } - - if (cutout != null) { - int widthInsets = cutout.getSafeInsetLeft() + cutout.getSafeInsetRight(); - int heightInsets = cutout.getSafeInsetBottom() + cutout.getSafeInsetTop(); - - if (widthInsets != 0 || heightInsets != 0) { - DisplayMetrics metrics = new DisplayMetrics(); - display.getRealMetrics(metrics); - - int width = Math.max(metrics.widthPixels - widthInsets, metrics.heightPixels - heightInsets); - int height = Math.min(metrics.widthPixels - widthInsets, metrics.heightPixels - heightInsets); - - addNativeResolutionEntries(width, height, false); - hasInsets = true; - } - } - } - - // Always allow resolutions that are smaller or equal to the active - // display resolution because decoders can report total non-sense to us. - // For example, a p201 device reports: - // AVC Decoder: OMX.amlogic.avc.decoder.awesome - // HEVC Decoder: OMX.amlogic.hevc.decoder.awesome - // AVC supported width range: 64 - 384 - // HEVC supported width range: 64 - 544 - for (Display.Mode candidate : display.getSupportedModes()) { - // Some devices report their dimensions in the portrait orientation - // where height > width. Normalize these to the conventional width > height - // arrangement before we process them. - - int width = Math.max(candidate.getPhysicalWidth(), candidate.getPhysicalHeight()); - int height = Math.min(candidate.getPhysicalWidth(), candidate.getPhysicalHeight()); - - // Some TVs report strange values here, so let's avoid native resolutions on a TV - // unless they report greater than 4K resolutions. - if (!getActivity().getPackageManager().hasSystemFeature(PackageManager.FEATURE_TELEVISION) || - (width > 3840 || height > 2160)) { - addNativeResolutionEntries(width, height, hasInsets); - } - - if ((width >= 3840 || height >= 2160) && maxSupportedResW < 3840) { - maxSupportedResW = 3840; - } - else if ((width >= 2560 || height >= 1440) && maxSupportedResW < 2560) { - maxSupportedResW = 2560; - } - else if ((width >= 1920 || height >= 1080) && maxSupportedResW < 1920) { - maxSupportedResW = 1920; - } - - if (candidate.getRefreshRate() > maxSupportedFps) { - maxSupportedFps = candidate.getRefreshRate(); - } - } - - // This must be called to do runtime initialization before calling functions that evaluate - // decoder lists. - MediaCodecHelper.initialize(getContext(), GlPreferences.readPreferences(getContext()).glRenderer); - - MediaCodecInfo avcDecoder = MediaCodecHelper.findProbableSafeDecoder("video/avc", -1); - MediaCodecInfo hevcDecoder = MediaCodecHelper.findProbableSafeDecoder("video/hevc", -1); - - if (avcDecoder != null) { - Range avcWidthRange = avcDecoder.getCapabilitiesForType("video/avc").getVideoCapabilities().getSupportedWidths(); - - LimeLog.info("AVC supported width range: "+avcWidthRange.getLower()+" - "+avcWidthRange.getUpper()); - - // If 720p is not reported as supported, ignore all results from this API - if (avcWidthRange.contains(1280)) { - if (avcWidthRange.contains(3840) && maxSupportedResW < 3840) { - maxSupportedResW = 3840; - } - else if (avcWidthRange.contains(1920) && maxSupportedResW < 1920) { - maxSupportedResW = 1920; - } - else if (maxSupportedResW < 1280) { - maxSupportedResW = 1280; - } - } - } - - if (hevcDecoder != null) { - Range hevcWidthRange = hevcDecoder.getCapabilitiesForType("video/hevc").getVideoCapabilities().getSupportedWidths(); - - LimeLog.info("HEVC supported width range: "+hevcWidthRange.getLower()+" - "+hevcWidthRange.getUpper()); - - // If 720p is not reported as supported, ignore all results from this API - if (hevcWidthRange.contains(1280)) { - if (hevcWidthRange.contains(3840) && maxSupportedResW < 3840) { - maxSupportedResW = 3840; - } - else if (hevcWidthRange.contains(1920) && maxSupportedResW < 1920) { - maxSupportedResW = 1920; - } - else if (maxSupportedResW < 1280) { - maxSupportedResW = 1280; - } - } - } - - LimeLog.info("Maximum resolution slot: "+maxSupportedResW); - - if (maxSupportedResW != 0) { - if (maxSupportedResW < 3840) { - // 4K is unsupported - removeValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.RES_4K, new Runnable() { - @Override - public void run() { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); - setValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.RES_1440P); - resetBitrateToDefault(prefs, null, null); - } - }); - } - if (maxSupportedResW < 2560) { - // 1440p is unsupported - removeValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.RES_1440P, new Runnable() { - @Override - public void run() { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); - setValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.RES_1080P); - resetBitrateToDefault(prefs, null, null); - } - }); - } - if (maxSupportedResW < 1920) { - // 1080p is unsupported - removeValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.RES_1080P, new Runnable() { - @Override - public void run() { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); - setValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.RES_720P); - resetBitrateToDefault(prefs, null, null); - } - }); - } - // Never remove 720p - } - } - else { - // We can get the true metrics via the getRealMetrics() function (unlike the lies - // that getWidth() and getHeight() tell to us). - DisplayMetrics metrics = new DisplayMetrics(); - display.getRealMetrics(metrics); - int width = Math.max(metrics.widthPixels, metrics.heightPixels); - int height = Math.min(metrics.widthPixels, metrics.heightPixels); - addNativeResolutionEntries(width, height, false); - } - - if (!PreferenceConfiguration.readPreferences(this.getActivity()).unlockFps) { - // We give some extra room in case the FPS is rounded down - if (maxSupportedFps < 118) { - removeValue(PreferenceConfiguration.FPS_PREF_STRING, "120", new Runnable() { - @Override - public void run() { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); - setValue(PreferenceConfiguration.FPS_PREF_STRING, "90"); - resetBitrateToDefault(prefs, null, null); - } - }); - } - if (maxSupportedFps < 88) { - // 1080p is unsupported - removeValue(PreferenceConfiguration.FPS_PREF_STRING, "90", new Runnable() { - @Override - public void run() { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); - setValue(PreferenceConfiguration.FPS_PREF_STRING, "60"); - resetBitrateToDefault(prefs, null, null); - } - }); - } - // Never remove 30 FPS or 60 FPS - } - addNativeFrameRateEntry(maxSupportedFps); - - // Android L introduces the drop duplicate behavior of releaseOutputBuffer() - // that the unlock FPS option relies on to not massively increase latency. - findPreference(PreferenceConfiguration.UNLOCK_FPS_STRING).setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { - @Override - public boolean onPreferenceChange(Preference preference, Object newValue) { - // HACK: We need to let the preference change succeed before reinitializing to ensure - // it's reflected in the new layout. - final Handler h = new Handler(); - h.postDelayed(new Runnable() { - @Override - public void run() { - // Ensure the activity is still open when this timeout expires - StreamSettings settingsActivity = (StreamSettings) SettingsFragment.this.getActivity(); - if (settingsActivity != null) { - settingsActivity.reloadSettings(); - } - } - }, 500); - - // Allow the original preference change to take place - return true; - } - }); - - // Remove HDR preference for devices below Nougat - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { - LimeLog.info("Excluding HDR toggle based on OS"); - PreferenceCategory category = - (PreferenceCategory) findPreference("category_advanced_settings"); - category.removePreference(findPreference("checkbox_enable_hdr")); - } - else { - Display.HdrCapabilities hdrCaps = display.getHdrCapabilities(); - - // We must now ensure our display is compatible with HDR10 - boolean foundHdr10 = false; - if (hdrCaps != null) { - // getHdrCapabilities() returns null on Lenovo Lenovo Mirage Solo (vega), Android 8.0 - for (int hdrType : hdrCaps.getSupportedHdrTypes()) { - if (hdrType == Display.HdrCapabilities.HDR_TYPE_HDR10) { - foundHdr10 = true; - break; - } - } - } - - if (!foundHdr10) { - LimeLog.info("Excluding HDR toggle based on display capabilities"); - PreferenceCategory category = - (PreferenceCategory) findPreference("category_advanced_settings"); - category.removePreference(findPreference("checkbox_enable_hdr")); - } - else if (PreferenceConfiguration.isShieldAtvFirmwareWithBrokenHdr()) { - LimeLog.info("Disabling HDR toggle on old broken SHIELD TV firmware"); - PreferenceCategory category = - (PreferenceCategory) findPreference("category_advanced_settings"); - CheckBoxPreference hdrPref = (CheckBoxPreference) category.findPreference("checkbox_enable_hdr"); - hdrPref.setEnabled(false); - hdrPref.setChecked(false); - hdrPref.setSummary("Update the firmware on your NVIDIA SHIELD Android TV to enable HDR"); - } - } - - // Add a listener to the FPS and resolution preference - // so the bitrate can be auto-adjusted - findPreference(PreferenceConfiguration.RESOLUTION_PREF_STRING).setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { - @Override - public boolean onPreferenceChange(Preference preference, Object newValue) { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); - String valueStr = (String) newValue; - - // Detect if this value is the native resolution option - CharSequence[] values = ((ListPreference)preference).getEntryValues(); - boolean isNativeRes = true; - for (int i = 0; i < values.length; i++) { - // Look for a match prior to the start of the native resolution entries - if (valueStr.equals(values[i].toString()) && i < nativeResolutionStartIndex) { - isNativeRes = false; - break; - } - } - - // If this is native resolution, show the warning dialog - if (isNativeRes) { - Dialog.displayDialog(getActivity(), - getResources().getString(R.string.title_native_res_dialog), - getResources().getString(R.string.text_native_res_dialog), - false); - } - - // Write the new bitrate value - resetBitrateToDefault(prefs, valueStr, null); - - // Allow the original preference change to take place - return true; - } - }); - findPreference(PreferenceConfiguration.FPS_PREF_STRING).setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { - @Override - public boolean onPreferenceChange(Preference preference, Object newValue) { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); - String valueStr = (String) newValue; - - // If this is native frame rate, show the warning dialog - CharSequence[] values = ((ListPreference)preference).getEntryValues(); - if (nativeFramerateShown && values[values.length - 1].toString().equals(newValue.toString())) { - Dialog.displayDialog(getActivity(), - getResources().getString(R.string.title_native_fps_dialog), - getResources().getString(R.string.text_native_res_dialog), - false); - } - - // Write the new bitrate value - resetBitrateToDefault(prefs, null, valueStr); - - // Allow the original preference change to take place - return true; - } - }); - } - } -} +package com.limelight.preferences; + +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.content.res.Configuration; +import android.media.MediaCodecInfo; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.app.Activity; +import android.os.Handler; +import android.os.Vibrator; +import android.preference.CheckBoxPreference; +import android.preference.EditTextPreference; +import android.preference.ListPreference; +import android.preference.Preference; +import android.preference.PreferenceCategory; +import android.preference.PreferenceFragment; +import android.preference.PreferenceManager; +import android.preference.PreferenceScreen; +import android.support.v4.content.FileProvider; +import android.text.InputFilter; +import android.text.InputType; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.method.NumberKeyListener; +import android.util.DisplayMetrics; +import android.util.Range; +import android.view.Display; +import android.view.DisplayCutout; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowInsets; +import android.widget.EditText; +import android.widget.Toast; +import com.google.gson.Gson; +import com.limelight.AxiTestActivity; +import com.limelight.BuildConfig; +import com.limelight.GameMenu; +import com.limelight.LimeLog; +import com.limelight.PcView; +import com.limelight.R; +import com.limelight.binding.input.virtual_controller.keyboard.KeyBoardControllerConfigurationLoader; +import com.limelight.binding.video.MediaCodecHelper; +import com.limelight.utils.Dialog; +import com.limelight.utils.FileUriUtils; +import com.limelight.utils.UiHelper; +import org.json.JSONObject; +import java.io.File; +import java.text.DecimalFormat; +import java.util.Arrays; +import java.util.Iterator; +import java.util.Map; + +public class StreamSettings extends Activity { + private PreferenceConfiguration previousPrefs; + private int previousDisplayPixelCount; + + // HACK for Android 9 + static DisplayCutout displayCutoutP; + + void reloadSettings() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + Display.Mode mode = getWindowManager().getDefaultDisplay().getMode(); + previousDisplayPixelCount = mode.getPhysicalWidth() * mode.getPhysicalHeight(); + } + getFragmentManager().beginTransaction().replace( + R.id.stream_settings, new SettingsFragment() + ).commitAllowingStateLoss(); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + previousPrefs = PreferenceConfiguration.readPreferences(this); + + UiHelper.setLocale(this); + + setContentView(R.layout.activity_stream_settings); + + UiHelper.notifyNewRootView(this); + } + + @Override + public void onAttachedToWindow() { + super.onAttachedToWindow(); + + // We have to use this hack on Android 9 because we don't have Display.getCutout() + // which was added in Android 10. + if (Build.VERSION.SDK_INT == Build.VERSION_CODES.P) { + // Insets can be null when the activity is recreated on screen rotation + // https://stackoverflow.com/questions/61241255/windowinsets-getdisplaycutout-is-null-everywhere-except-within-onattachedtowindo + WindowInsets insets = getWindow().getDecorView().getRootWindowInsets(); + if (insets != null) { + displayCutoutP = insets.getDisplayCutout(); + } + } + + reloadSettings(); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + Display.Mode mode = getWindowManager().getDefaultDisplay().getMode(); + + // If the display's physical pixel count has changed, we consider that it's a new display + // and we should reload our settings (which include display-dependent values). + // + // NB: We aren't using displayId here because that stays the same (DEFAULT_DISPLAY) when + // switching between screens on a foldable device. + if (mode.getPhysicalWidth() * mode.getPhysicalHeight() != previousDisplayPixelCount) { + reloadSettings(); + } + } + } + + @Override + // NOTE: This will NOT be called on Android 13+ with android:enableOnBackInvokedCallback="true" + public void onBackPressed() { + finish(); + + // Language changes are handled via configuration changes in Android 13+, + // so manual activity relaunching is no longer required. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + PreferenceConfiguration newPrefs = PreferenceConfiguration.readPreferences(this); + if (!newPrefs.language.equals(previousPrefs.language)) { + // Restart the PC view to apply UI changes + Intent intent = new Intent(this, PcView.class); + intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(intent, null); + } + } + } + + public static class SettingsFragment extends PreferenceFragment { + private int nativeResolutionStartIndex = Integer.MAX_VALUE; + private boolean nativeFramerateShown = false; + + private void setValue(String preferenceKey, String value) { + ListPreference pref = (ListPreference) findPreference(preferenceKey); + + pref.setValue(value); + } + + private void appendPreferenceEntry(ListPreference pref, String newEntryName, String newEntryValue) { + CharSequence[] newEntries = Arrays.copyOf(pref.getEntries(), pref.getEntries().length + 1); + CharSequence[] newValues = Arrays.copyOf(pref.getEntryValues(), pref.getEntryValues().length + 1); + + // Add the new option + newEntries[newEntries.length - 1] = newEntryName; + newValues[newValues.length - 1] = newEntryValue; + + pref.setEntries(newEntries); + pref.setEntryValues(newValues); + } + + private void addNativeResolutionEntry(int nativeWidth, int nativeHeight, boolean insetsRemoved, boolean portrait) { + ListPreference pref = (ListPreference) findPreference(PreferenceConfiguration.RESOLUTION_PREF_STRING); + + String newName; + + if (insetsRemoved) { + newName = getResources().getString(R.string.resolution_prefix_native_fullscreen); + } + else { + newName = getResources().getString(R.string.resolution_prefix_native); + } + + if (PreferenceConfiguration.isSquarishScreen(nativeWidth, nativeHeight)) { + if (portrait) { + newName += " " + getResources().getString(R.string.resolution_prefix_native_portrait); + } + else { + newName += " " + getResources().getString(R.string.resolution_prefix_native_landscape); + } + } + + newName += " ("+nativeWidth+"x"+nativeHeight+")"; + + String newValue = nativeWidth+"x"+nativeHeight; + + // Check if the native resolution is already present + for (CharSequence value : pref.getEntryValues()) { + if (newValue.equals(value.toString())) { + // It is present in the default list, so don't add it again + return; + } + } + + if (pref.getEntryValues().length < nativeResolutionStartIndex) { + nativeResolutionStartIndex = pref.getEntryValues().length; + } + appendPreferenceEntry(pref, newName, newValue); + } + + private void addNativeResolutionEntries(int nativeWidth, int nativeHeight, boolean insetsRemoved) { + if (PreferenceConfiguration.isSquarishScreen(nativeWidth, nativeHeight)) { + addNativeResolutionEntry(nativeHeight, nativeWidth, insetsRemoved, true); + } + addNativeResolutionEntry(nativeWidth, nativeHeight, insetsRemoved, false); + } + + private void addNativeFrameRateEntry(float framerate) { + int frameRateRounded = Math.round(framerate); + if (frameRateRounded == 0) { + return; + } + + ListPreference pref = (ListPreference) findPreference(PreferenceConfiguration.FPS_PREF_STRING); + String fpsValue = Integer.toString(frameRateRounded); + String fpsName = getResources().getString(R.string.resolution_prefix_native) + + " (" + fpsValue + " " + getResources().getString(R.string.fps_suffix_fps) + ")"; + + // Check if the native frame rate is already present + for (CharSequence value : pref.getEntryValues()) { + if (fpsValue.equals(value.toString())) { + // It is present in the default list, so don't add it again + nativeFramerateShown = false; + return; + } + } + + appendPreferenceEntry(pref, fpsName, fpsValue); + nativeFramerateShown = true; + } + + private void removeValue(String preferenceKey, String value, Runnable onMatched) { + int matchingCount = 0; + + ListPreference pref = (ListPreference) findPreference(preferenceKey); + + // Count the number of matching entries we'll be removing + for (CharSequence seq : pref.getEntryValues()) { + if (seq.toString().equalsIgnoreCase(value)) { + matchingCount++; + } + } + + // Create the new arrays + CharSequence[] entries = new CharSequence[pref.getEntries().length-matchingCount]; + CharSequence[] entryValues = new CharSequence[pref.getEntryValues().length-matchingCount]; + int outIndex = 0; + for (int i = 0; i < pref.getEntryValues().length; i++) { + if (pref.getEntryValues()[i].toString().equalsIgnoreCase(value)) { + // Skip matching values + continue; + } + + entries[outIndex] = pref.getEntries()[i]; + entryValues[outIndex] = pref.getEntryValues()[i]; + outIndex++; + } + + if (pref.getValue().equalsIgnoreCase(value)) { + onMatched.run(); + } + + // Update the preference with the new list + pref.setEntries(entries); + pref.setEntryValues(entryValues); + } + + private void resetBitrateToDefault(SharedPreferences prefs, String res, String fps) { + if (res == null) { + res = prefs.getString(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.DEFAULT_RESOLUTION); + } + if (fps == null) { + fps = prefs.getString(PreferenceConfiguration.FPS_PREF_STRING, PreferenceConfiguration.DEFAULT_FPS); + } + + prefs.edit() + .putInt(PreferenceConfiguration.BITRATE_PREF_STRING, + PreferenceConfiguration.getDefaultBitrate(res, fps)) + .apply(); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View view = super.onCreateView(inflater, container, savedInstanceState); + UiHelper.applyStatusBarPadding(view); + return view; + } + + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + addPreferencesFromResource(R.xml.preferences); + PreferenceScreen screen = getPreferenceScreen(); + + // hide on-screen controls category on non touch screen devices + if (!getActivity().getPackageManager().hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN)) { + PreferenceCategory category = + (PreferenceCategory) findPreference("category_onscreen_controls"); + screen.removePreference(category); + } + + // Hide remote desktop mouse mode on pre-Oreo (which doesn't have pointer capture) + // and NVIDIA SHIELD devices (which support raw mouse input in pointer capture mode) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || + getActivity().getPackageManager().hasSystemFeature("com.nvidia.feature.shield")) { + PreferenceCategory category = + (PreferenceCategory) findPreference("category_input_settings"); + category.removePreference(findPreference("checkbox_absolute_mouse_mode")); + } + + // Hide gamepad motion sensor option when running on OSes before Android 12. + // Support for motion, LED, battery, and other extensions were introduced in S. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + PreferenceCategory category = + (PreferenceCategory) findPreference("category_gamepad_settings"); + category.removePreference(findPreference("checkbox_gamepad_motion_sensors")); + } + + // Hide gamepad motion sensor fallback option if the device has no gyro or accelerometer + if (!getActivity().getPackageManager().hasSystemFeature(PackageManager.FEATURE_SENSOR_ACCELEROMETER) && + !getActivity().getPackageManager().hasSystemFeature(PackageManager.FEATURE_SENSOR_GYROSCOPE)) { + PreferenceCategory category = + (PreferenceCategory) findPreference("category_gamepad_settings"); + category.removePreference(findPreference("checkbox_gamepad_motion_fallback")); + } + + // Hide USB driver options on devices without USB host support + if (!getActivity().getPackageManager().hasSystemFeature(PackageManager.FEATURE_USB_HOST)) { + PreferenceCategory category = + (PreferenceCategory) findPreference("category_gamepad_settings"); + category.removePreference(findPreference("checkbox_usb_bind_all")); + category.removePreference(findPreference("checkbox_usb_driver")); + } + + // Remove PiP mode on devices pre-Oreo, where the feature is not available (some low RAM devices), + // and on Fire OS where it violates the Amazon App Store guidelines for some reason. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || + !getActivity().getPackageManager().hasSystemFeature("android.software.picture_in_picture") || + getActivity().getPackageManager().hasSystemFeature("com.amazon.software.fireos")) { + PreferenceCategory category = + (PreferenceCategory) findPreference("category_ui_settings"); + category.removePreference(findPreference("checkbox_enable_pip")); + } + + // Fire TV apps are not allowed to use WebViews or browsers, so hide the Help category + /*if (getActivity().getPackageManager().hasSystemFeature("amazon.hardware.fire_tv")) { + PreferenceCategory category = + (PreferenceCategory) findPreference("category_help"); + screen.removePreference(category); + }*/ + PreferenceCategory category_gamepad_settings = + (PreferenceCategory) findPreference("category_gamepad_settings"); + // Remove the vibration options if the device can't vibrate + if (!((Vibrator)getActivity().getSystemService(Context.VIBRATOR_SERVICE)).hasVibrator()) { + category_gamepad_settings.removePreference(findPreference("checkbox_vibrate_fallback")); + category_gamepad_settings.removePreference(findPreference("seekbar_vibrate_fallback_strength")); + // The entire OSC category may have already been removed by the touchscreen check above + PreferenceCategory category = (PreferenceCategory) findPreference("category_onscreen_controls"); + if (category != null) { + category.removePreference(findPreference("checkbox_vibrate_osc")); + } + } + else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || + !((Vibrator)getActivity().getSystemService(Context.VIBRATOR_SERVICE)).hasAmplitudeControl() ) { + // Remove the vibration strength selector of the device doesn't have amplitude control + category_gamepad_settings.removePreference(findPreference("seekbar_vibrate_fallback_strength")); + } + + String diy=PreferenceManager.getDefaultSharedPreferences(this.getActivity()).getString("edit_diy_w_h",""); + if(!TextUtils.isEmpty(diy)){ + String[] diys=diy.split("x"); + if(diys.length==2){ + try{ + addNativeResolutionEntries(Integer.parseInt(diys[0]), Integer.parseInt(diys[1]), false); + }catch (Exception e){ + e.printStackTrace(); + } + } + } + + Display display = getActivity().getWindowManager().getDefaultDisplay(); + float maxSupportedFps = display.getRefreshRate(); + + // Hide non-supported resolution/FPS combinations + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + int maxSupportedResW = 0; + + // Add a native resolution with any insets included for users that don't want content + // behind the notch of their display + boolean hasInsets = false; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + DisplayCutout cutout; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // Use the much nicer Display.getCutout() API on Android 10+ + cutout = display.getCutout(); + } + else { + // Android 9 only + cutout = displayCutoutP; + } + + if (cutout != null) { + int widthInsets = cutout.getSafeInsetLeft() + cutout.getSafeInsetRight(); + int heightInsets = cutout.getSafeInsetBottom() + cutout.getSafeInsetTop(); + + if (widthInsets != 0 || heightInsets != 0) { + DisplayMetrics metrics = new DisplayMetrics(); + display.getRealMetrics(metrics); + + int width = Math.max(metrics.widthPixels - widthInsets, metrics.heightPixels - heightInsets); + int height = Math.min(metrics.widthPixels - widthInsets, metrics.heightPixels - heightInsets); + + addNativeResolutionEntries(width, height, false); + hasInsets = true; + } + } + } + + // Always allow resolutions that are smaller or equal to the active + // display resolution because decoders can report total non-sense to us. + // For example, a p201 device reports: + // AVC Decoder: OMX.amlogic.avc.decoder.awesome + // HEVC Decoder: OMX.amlogic.hevc.decoder.awesome + // AVC supported width range: 64 - 384 + // HEVC supported width range: 64 - 544 + for (Display.Mode candidate : display.getSupportedModes()) { + // Some devices report their dimensions in the portrait orientation + // where height > width. Normalize these to the conventional width > height + // arrangement before we process them. + + int width = Math.max(candidate.getPhysicalWidth(), candidate.getPhysicalHeight()); + int height = Math.min(candidate.getPhysicalWidth(), candidate.getPhysicalHeight()); + + // Some TVs report strange values here, so let's avoid native resolutions on a TV + // unless they report greater than 4K resolutions. + if (!getActivity().getPackageManager().hasSystemFeature(PackageManager.FEATURE_TELEVISION) || + (width > 3840 || height > 2160)) { + addNativeResolutionEntries(width, height, hasInsets); + } + + if ((width >= 3840 || height >= 2160) && maxSupportedResW < 3840) { + maxSupportedResW = 3840; + } + else if ((width >= 2560 || height >= 1440) && maxSupportedResW < 2560) { + maxSupportedResW = 2560; + } + else if ((width >= 1920 || height >= 1080) && maxSupportedResW < 1920) { + maxSupportedResW = 1920; + } + + if (candidate.getRefreshRate() > maxSupportedFps) { + maxSupportedFps = candidate.getRefreshRate(); + } + } + + // This must be called to do runtime initialization before calling functions that evaluate + // decoder lists. + MediaCodecHelper.initialize(getContext(), GlPreferences.readPreferences(getContext()).glRenderer); + + MediaCodecInfo avcDecoder = MediaCodecHelper.findProbableSafeDecoder("video/avc", -1); + MediaCodecInfo hevcDecoder = MediaCodecHelper.findProbableSafeDecoder("video/hevc", -1); + + if (avcDecoder != null) { + Range avcWidthRange = avcDecoder.getCapabilitiesForType("video/avc").getVideoCapabilities().getSupportedWidths(); + + LimeLog.info("AVC supported width range: "+avcWidthRange.getLower()+" - "+avcWidthRange.getUpper()); + + // If 720p is not reported as supported, ignore all results from this API + if (avcWidthRange.contains(1280)) { + if (avcWidthRange.contains(3840) && maxSupportedResW < 3840) { + maxSupportedResW = 3840; + } + else if (avcWidthRange.contains(1920) && maxSupportedResW < 1920) { + maxSupportedResW = 1920; + } + else if (maxSupportedResW < 1280) { + maxSupportedResW = 1280; + } + } + } + + if (hevcDecoder != null) { + Range hevcWidthRange = hevcDecoder.getCapabilitiesForType("video/hevc").getVideoCapabilities().getSupportedWidths(); + + LimeLog.info("HEVC supported width range: "+hevcWidthRange.getLower()+" - "+hevcWidthRange.getUpper()); + + // If 720p is not reported as supported, ignore all results from this API + if (hevcWidthRange.contains(1280)) { + if (hevcWidthRange.contains(3840) && maxSupportedResW < 3840) { + maxSupportedResW = 3840; + } + else if (hevcWidthRange.contains(1920) && maxSupportedResW < 1920) { + maxSupportedResW = 1920; + } + else if (maxSupportedResW < 1280) { + maxSupportedResW = 1280; + } + } + } + + LimeLog.info("Maximum resolution slot: "+maxSupportedResW); + + if (maxSupportedResW != 0) { + if (maxSupportedResW < 3840) { + // 4K is unsupported + removeValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.RES_4K, new Runnable() { + @Override + public void run() { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); + setValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.RES_1440P); + resetBitrateToDefault(prefs, null, null); + } + }); + } + if (maxSupportedResW < 2560) { + // 1440p is unsupported + removeValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.RES_1440P, new Runnable() { + @Override + public void run() { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); + setValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.RES_1080P); + resetBitrateToDefault(prefs, null, null); + } + }); + } + if (maxSupportedResW < 1920) { + // 1080p is unsupported + removeValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.RES_1080P, new Runnable() { + @Override + public void run() { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); + setValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.RES_720P); + resetBitrateToDefault(prefs, null, null); + } + }); + } + // Never remove 720p + } + } + else { + // We can get the true metrics via the getRealMetrics() function (unlike the lies + // that getWidth() and getHeight() tell to us). + DisplayMetrics metrics = new DisplayMetrics(); + display.getRealMetrics(metrics); + int width = Math.max(metrics.widthPixels, metrics.heightPixels); + int height = Math.min(metrics.widthPixels, metrics.heightPixels); + addNativeResolutionEntries(width, height, false); + } + + if (!PreferenceConfiguration.readPreferences(this.getActivity()).unlockFps) { + // We give some extra room in case the FPS is rounded down + if (maxSupportedFps < 118) { + removeValue(PreferenceConfiguration.FPS_PREF_STRING, "120", new Runnable() { + @Override + public void run() { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); + setValue(PreferenceConfiguration.FPS_PREF_STRING, "90"); + resetBitrateToDefault(prefs, null, null); + } + }); + } + if (maxSupportedFps < 88) { + // 1080p is unsupported + removeValue(PreferenceConfiguration.FPS_PREF_STRING, "90", new Runnable() { + @Override + public void run() { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); + setValue(PreferenceConfiguration.FPS_PREF_STRING, "60"); + resetBitrateToDefault(prefs, null, null); + } + }); + } + // Never remove 30 FPS or 60 FPS + } + addNativeFrameRateEntry(maxSupportedFps); + + // Android L introduces the drop duplicate behavior of releaseOutputBuffer() + // that the unlock FPS option relies on to not massively increase latency. + findPreference(PreferenceConfiguration.UNLOCK_FPS_STRING).setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { + @Override + public boolean onPreferenceChange(Preference preference, Object newValue) { + // HACK: We need to let the preference change succeed before reinitializing to ensure + // it's reflected in the new layout. + final Handler h = new Handler(); + h.postDelayed(new Runnable() { + @Override + public void run() { + // Ensure the activity is still open when this timeout expires + StreamSettings settingsActivity = (StreamSettings) SettingsFragment.this.getActivity(); + if (settingsActivity != null) { + settingsActivity.reloadSettings(); + } + } + }, 500); + + // Allow the original preference change to take place + return true; + } + }); + + // Remove HDR preference for devices below Nougat + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + LimeLog.info("Excluding HDR toggle based on OS"); + PreferenceCategory category = + (PreferenceCategory) findPreference("category_advanced_settings"); + category.removePreference(findPreference("checkbox_enable_hdr")); + } + else { + Display.HdrCapabilities hdrCaps = display.getHdrCapabilities(); + + // We must now ensure our display is compatible with HDR10 + boolean foundHdr10 = false; + if (hdrCaps != null) { + // getHdrCapabilities() returns null on Lenovo Lenovo Mirage Solo (vega), Android 8.0 + for (int hdrType : hdrCaps.getSupportedHdrTypes()) { + if (hdrType == Display.HdrCapabilities.HDR_TYPE_HDR10) { + foundHdr10 = true; + break; + } + } + } + + if (!foundHdr10) { + LimeLog.info("Excluding HDR toggle based on display capabilities"); + PreferenceCategory category = + (PreferenceCategory) findPreference("category_advanced_settings"); + category.removePreference(findPreference("checkbox_enable_hdr")); + } + else if (PreferenceConfiguration.isShieldAtvFirmwareWithBrokenHdr()) { + LimeLog.info("Disabling HDR toggle on old broken SHIELD TV firmware"); + PreferenceCategory category = + (PreferenceCategory) findPreference("category_advanced_settings"); + CheckBoxPreference hdrPref = (CheckBoxPreference) category.findPreference("checkbox_enable_hdr"); + hdrPref.setEnabled(false); + hdrPref.setChecked(false); + hdrPref.setSummary("Update the firmware on your NVIDIA SHIELD Android TV to enable HDR"); + } + } + + // Add a listener to the FPS and resolution preference + // so the bitrate can be auto-adjusted + findPreference(PreferenceConfiguration.RESOLUTION_PREF_STRING).setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { + @Override + public boolean onPreferenceChange(Preference preference, Object newValue) { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); + String valueStr = (String) newValue; + + // Detect if this value is the native resolution option + CharSequence[] values = ((ListPreference)preference).getEntryValues(); + boolean isNativeRes = true; + for (int i = 0; i < values.length; i++) { + // Look for a match prior to the start of the native resolution entries + if (valueStr.equals(values[i].toString()) && i < nativeResolutionStartIndex) { + isNativeRes = false; + break; + } + } + + // If this is native resolution, show the warning dialog + if (isNativeRes) { + Dialog.displayDialog(getActivity(), + getResources().getString(R.string.title_native_res_dialog), + getResources().getString(R.string.text_native_res_dialog), + false); + } + + // Write the new bitrate value + resetBitrateToDefault(prefs, valueStr, null); + + // Allow the original preference change to take place + return true; + } + }); + findPreference(PreferenceConfiguration.FPS_PREF_STRING).setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { + @Override + public boolean onPreferenceChange(Preference preference, Object newValue) { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); + String valueStr = (String) newValue; + + // If this is native frame rate, show the warning dialog + CharSequence[] values = ((ListPreference)preference).getEntryValues(); + if (nativeFramerateShown && values[values.length - 1].toString().equals(newValue.toString())) { + Dialog.displayDialog(getActivity(), + getResources().getString(R.string.title_native_fps_dialog), + getResources().getString(R.string.text_native_res_dialog), + false); + } + + // Write the new bitrate value + resetBitrateToDefault(prefs, null, valueStr); + + // Allow the original preference change to take place + return true; + } + }); + + findPreference("import_keyboard_file").setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("text/plain"); + startActivityForResult(intent, READ_REQUEST_CODE); + return false; + } + }); + findPreference("import_special_button_file").setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("application/json"); + startActivityForResult(intent, READ_REQUEST_SPECIAL_CODE); + return false; + } + }); + + + findPreference("export_keyboard_file").setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + File file = new File(getActivity().getExternalCacheDir(),"export_settings"); + if(!file.exists()){ + file.mkdir(); + } + File file1= getJsonContent(getActivity(),file); + if(file1==null){ + Toast.makeText(getActivity(),"出错啦~",Toast.LENGTH_SHORT).show(); + return false; + } + Uri uri; + Intent intent = new Intent(Intent.ACTION_SEND); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + String authority= BuildConfig.APPLICATION_ID+".fileprovider"; + uri= FileProvider.getUriForFile(getActivity(),authority,file1); + intent.putExtra(Intent.EXTRA_STREAM, uri); + intent.setType("text/plain"); + startActivity(Intent.createChooser(intent,"保存配置文件")); + return false; + } + }); + + + findPreference("pref_axi_test").setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + Intent intent=new Intent(getActivity(), AxiTestActivity.class); + getActivity().startActivity(intent); + return false; + } + }); + + EditTextPreference bitrateEditPre= (EditTextPreference) findPreference("edit_diy_bitrate"); + EditText editText=bitrateEditPre.getEditText(); + + editText.setInputType(InputType.TYPE_NUMBER_FLAG_DECIMAL); + +// editText.setKeyListener(new NumberKeyListener() { +// @Override +// public int getInputType() { +// return InputType.TYPE_MASK_VARIATION; +// } +// @Override +// protected char[] getAcceptedChars() {/*这里实现字符串过滤,把你允许输入的字母添加到下面的数组即可!*/ +// return new char[]{'0', '1', '2', '3', '4', '5','6','7', '8', '9', '.'}; +// } +// }); + editText.setFilters(new InputFilter[]{new InputFilter.LengthFilter(5)/*这里限制输入的长度为5个字母*/}); + + bitrateEditPre.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { + @Override + public boolean onPreferenceChange(Preference preference, Object newValue) { + String value= (String) newValue; + if(TextUtils.isEmpty(value)){ + Toast.makeText(getActivity(),"请输入0-9999的数值。",Toast.LENGTH_SHORT).show(); + return false; + } + float bitrateValue=Float.valueOf(value)*1000; + LimeLog.info("axi-bitrateValue:"+bitrateValue); + int bitrate= (int) bitrateValue; + LimeLog.info("axi-bitrate:"+bitrate); + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); + prefs.edit().putInt(PreferenceConfiguration.BITRATE_PREF_STRING,bitrate).apply(); + Toast.makeText(getActivity(),"设置成功!",Toast.LENGTH_SHORT).show(); + return true; + } + }); + + +// findPreference("checkbox_multi_touch_screen").setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { +// @Override +// public boolean onPreferenceChange(Preference preference, Object newValue) { +// +// if(((Boolean) newValue)){ +// CheckBoxPreference checkBoxPreference= (CheckBoxPreference) findPreference(PreferenceConfiguration.TOUCHSCREEN_TRACKPAD_PREF_STRING); +// checkBoxPreference.setChecked(false); +// } +// +// return true; +// } +// }); + } + int READ_REQUEST_CODE=1001; + int READ_REQUEST_SPECIAL_CODE=1002; + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == READ_REQUEST_CODE && resultCode == Activity.RESULT_OK &&data.getData()!=null) { + try { + Uri uri = data.getData(); + String json=FileUriUtils.openUriForRead(getActivity(),uri); + if(TextUtils.isEmpty(json)){ + Toast.makeText(getActivity(),"空文件~",Toast.LENGTH_SHORT).show(); + return; + } + String name = PreferenceManager.getDefaultSharedPreferences(getActivity()).getString(KeyBoardControllerConfigurationLoader.OSC_PREFERENCE, KeyBoardControllerConfigurationLoader.OSC_PREFERENCE_VALUE); + SharedPreferences.Editor prefEditor = getActivity().getSharedPreferences(name, Activity.MODE_PRIVATE).edit(); + JSONObject object=new JSONObject(json); + Iterator it = object.keys(); + prefEditor.clear(); + while(it.hasNext()) { + String key = (String) it.next();// 获得key + String value = object.getString(key);// 获得value + prefEditor.putString(key,value); + } + prefEditor.apply(); + Toast.makeText(getActivity(),"导入成功!",Toast.LENGTH_SHORT).show(); + } catch (Exception e) { + e.printStackTrace(); + Toast.makeText(getActivity(),"出错啦~"+e.getMessage(),Toast.LENGTH_SHORT).show(); + } + return; + } + + if (requestCode == READ_REQUEST_SPECIAL_CODE && resultCode == Activity.RESULT_OK &&data.getData()!=null) { + try { + Uri uri = data.getData(); + String json=FileUriUtils.openUriForRead(getActivity(),uri); + if(TextUtils.isEmpty(json)){ + Toast.makeText(getActivity(),"空文件~",Toast.LENGTH_SHORT).show(); + return; + } + SharedPreferences.Editor prefEditor = getActivity().getSharedPreferences(GameMenu.PREF_NAME, Activity.MODE_PRIVATE).edit(); + prefEditor.putString(GameMenu.KEY_NAME,json); + prefEditor.apply(); + Toast.makeText(getActivity(),"导入成功!",Toast.LENGTH_SHORT).show(); + } catch (Exception e) { + e.printStackTrace(); + Toast.makeText(getActivity(),"出错啦~"+e.getMessage(),Toast.LENGTH_SHORT).show(); + } + } + } + + + private File getJsonContent(Context context,File file){ + String name = PreferenceManager.getDefaultSharedPreferences(context).getString(KeyBoardControllerConfigurationLoader.OSC_PREFERENCE, KeyBoardControllerConfigurationLoader.OSC_PREFERENCE_VALUE); + SharedPreferences pref = context.getSharedPreferences(name, Activity.MODE_PRIVATE); + Map map = pref.getAll(); + File file1= new File(file,name+".txt"); + String jsonStr=new Gson().toJson(map); + if(!FileUriUtils.writerFileString(file1,jsonStr)){ + return null; + } + return file1; + } + + //获取所有设置项配置文件 + private File getAllJsonData(Context context,File file){ + SharedPreferences pref=PreferenceManager.getDefaultSharedPreferences(context); + Map map = pref.getAll(); + //获取适配电脑的数据库信息 +// List map= new ComputerDatabaseManager(context).getAllComputers(); + File file1= new File(file,"allJSON.txt"); + String jsonStr=new Gson().toJson(map); + if(!FileUriUtils.writerFileString(file1,jsonStr)){ + return null; + } + return file1; + } + } + + +} diff --git a/app/src/main/java/com/limelight/preferences/WebLauncherPreference.java b/app/src/main/java/com/limelight/preferences/WebLauncherPreference.java old mode 100644 new mode 100755 index d01a7b1e85..d5f842e443 --- a/app/src/main/java/com/limelight/preferences/WebLauncherPreference.java +++ b/app/src/main/java/com/limelight/preferences/WebLauncherPreference.java @@ -1,44 +1,44 @@ -package com.limelight.preferences; - -import android.annotation.TargetApi; -import android.content.Context; -import android.os.Build; -import android.preference.Preference; -import android.util.AttributeSet; - -import com.limelight.utils.HelpLauncher; - -public class WebLauncherPreference extends Preference { - private String url; - - public WebLauncherPreference(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - initialize(attrs); - } - - public WebLauncherPreference(Context context, AttributeSet attrs) { - super(context, attrs); - initialize(attrs); - } - - public WebLauncherPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { - super(context, attrs, defStyleAttr, defStyleRes); - initialize(attrs); - } - - private void initialize(AttributeSet attrs) { - if (attrs == null) { - throw new IllegalStateException("WebLauncherPreference must have attributes!"); - } - - url = attrs.getAttributeValue(null, "url"); - if (url == null) { - throw new IllegalStateException("WebLauncherPreference must have 'url' attribute!"); - } - } - - @Override - public void onClick() { - HelpLauncher.launchUrl(getContext(), url); - } -} +package com.limelight.preferences; + +import android.annotation.TargetApi; +import android.content.Context; +import android.os.Build; +import android.preference.Preference; +import android.util.AttributeSet; + +import com.limelight.utils.HelpLauncher; + +public class WebLauncherPreference extends Preference { + private String url; + + public WebLauncherPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + initialize(attrs); + } + + public WebLauncherPreference(Context context, AttributeSet attrs) { + super(context, attrs); + initialize(attrs); + } + + public WebLauncherPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + initialize(attrs); + } + + private void initialize(AttributeSet attrs) { + if (attrs == null) { + throw new IllegalStateException("WebLauncherPreference must have attributes!"); + } + + url = attrs.getAttributeValue(null, "url"); + if (url == null) { + throw new IllegalStateException("WebLauncherPreference must have 'url' attribute!"); + } + } + + @Override + public void onClick() { + HelpLauncher.launchUrl(getContext(), url); + } +} diff --git a/app/src/main/java/com/limelight/sbs/TextureSurfaceRenderer.java b/app/src/main/java/com/limelight/sbs/TextureSurfaceRenderer.java new file mode 100755 index 0000000000..2f54bb13b9 --- /dev/null +++ b/app/src/main/java/com/limelight/sbs/TextureSurfaceRenderer.java @@ -0,0 +1,222 @@ +/* +By Ahmed Hilali + +A derivative work based on: http://github.com/izacus/AndroidOpenGLVideoDemo/ +See LICENSE.txt + */ + +package com.limelight.sbs; + +import android.graphics.SurfaceTexture; + +import javax.microedition.khronos.egl.EGLConfig; +import javax.microedition.khronos.egl.EGLContext; +import javax.microedition.khronos.egl.EGLDisplay; +import javax.microedition.khronos.egl.EGLSurface; + +import android.opengl.GLUtils; +import android.util.Log; + +import javax.microedition.khronos.egl.EGL10; + +/** + * Renderer which initializes OpenGL 2.0 context on a passed surface and starts a rendering thread + * + * This class has to be subclassed to be used properly + */ +public abstract class TextureSurfaceRenderer implements Runnable +{ + private static final int EGL_OPENGL_ES2_BIT = 4; + private static final int EGL_CONTEXT_CLIENT_VERSION = 0x3098; + private static final String LOG_TAG = "SurfaceTest.GL"; + protected final SurfaceTexture texture; + private EGL10 egl; + private EGLDisplay eglDisplay; + private EGLContext eglContext; + private EGLSurface eglSurface; + + protected int width; + protected int height; + private boolean running; + + OnGlReadyListener onGlReadyListener; + + + /** + * @param texture Surface texture on which to render. This has to be called AFTER the texture became available + * @param width Width of the passed surface + * @param height Height of the passed surface + */ + public TextureSurfaceRenderer(SurfaceTexture texture, int width, int height, OnGlReadyListener listener) + { + this.onGlReadyListener = listener; + this.texture = texture; + this.width = width; + this.height = height; + this.running = true; + Thread thrd = new Thread(this); + thrd.start(); + } + + @Override + public void run() + { + initGL(); + initGLComponents(); + Log.d(LOG_TAG, "OpenGL init OK."); + + if(this.onGlReadyListener != null) { + this.onGlReadyListener.onGlReady(); + } + + while (running) + { + long loopStart = System.currentTimeMillis(); + pingFps(); + + if (draw()) + { + egl.eglSwapBuffers(eglDisplay, eglSurface); + } + + long waitDelta = 16 - (System.currentTimeMillis() - loopStart); // Targeting 60 fps, no need for faster + if (waitDelta > 0) + { + try + { + Thread.sleep(waitDelta); + } + catch (InterruptedException e) + { + continue; + } + } + } + + deinitGLComponents(); + deinitGL(); + } + + /** + * Main draw function, subclass this and add custom drawing code here. The rendering thread will attempt to limit + * FPS to 60 to keep CPU usage low. + */ + protected abstract boolean draw(); + + /** + * OpenGL component initialization funcion. This is called after OpenGL context has been initialized on the rendering thread. + * Subclass this and initialize shaders / textures / other GL related components here. + */ + protected abstract void initGLComponents(); + protected abstract void deinitGLComponents(); + + private long lastFpsOutput = 0; + private int frames; + private void pingFps() + { + if (lastFpsOutput == 0) + lastFpsOutput = System.currentTimeMillis(); + + frames ++; + + if (System.currentTimeMillis() - lastFpsOutput > 1000) + { + Log.d(LOG_TAG, "FPS: " + frames); + lastFpsOutput = System.currentTimeMillis(); + frames = 0; + } + } + + + /** + * Call when activity pauses. This stops the rendering thread and deinitializes OpenGL. + */ + public void onPause() + { + running = false; + } + + + private void initGL() + { + egl = (EGL10) EGLContext.getEGL(); + eglDisplay = egl.eglGetDisplay(EGL10.EGL_DEFAULT_DISPLAY); + + int[] version = new int[2]; + egl.eglInitialize(eglDisplay, version); + + EGLConfig eglConfig = chooseEglConfig(); + eglContext = createContext(egl, eglDisplay, eglConfig); + + eglSurface = egl.eglCreateWindowSurface(eglDisplay, eglConfig, texture, null); + + if (eglSurface == null || eglSurface == EGL10.EGL_NO_SURFACE) + { + throw new RuntimeException("GL Error: " + GLUtils.getEGLErrorString(egl.eglGetError())); + } + + if (!egl.eglMakeCurrent(eglDisplay, eglSurface, eglSurface, eglContext)) + { + throw new RuntimeException("GL Make current error: " + GLUtils.getEGLErrorString(egl.eglGetError())); + } + } + + private void deinitGL() + { + egl.eglMakeCurrent(eglDisplay, EGL10.EGL_NO_SURFACE, EGL10.EGL_NO_SURFACE, EGL10.EGL_NO_CONTEXT); + egl.eglDestroySurface(eglDisplay, eglSurface); + egl.eglDestroyContext(eglDisplay, eglContext); + egl.eglTerminate(eglDisplay); + Log.d(LOG_TAG, "OpenGL deinit OK."); + } + + private EGLContext createContext(EGL10 egl, EGLDisplay eglDisplay, EGLConfig eglConfig) + { + int[] attribList = { EGL_CONTEXT_CLIENT_VERSION, 2, EGL10.EGL_NONE }; + return egl.eglCreateContext(eglDisplay, eglConfig, EGL10.EGL_NO_CONTEXT, attribList); + } + + private EGLConfig chooseEglConfig() + { + int[] configsCount = new int[1]; + EGLConfig[] configs = new EGLConfig[1]; + int[] configSpec = getConfig(); + + if (!egl.eglChooseConfig(eglDisplay, configSpec, configs, 1, configsCount)) + { + throw new IllegalArgumentException("Failed to choose config: " + GLUtils.getEGLErrorString(egl.eglGetError())); + } + else if (configsCount[0] > 0) + { + return configs[0]; + } + + return null; + } + + private int[] getConfig() + { + return new int[] { + EGL10.EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT, + EGL10.EGL_RED_SIZE, 8, + EGL10.EGL_GREEN_SIZE, 8, + EGL10.EGL_BLUE_SIZE, 8, + EGL10.EGL_ALPHA_SIZE, 8, + EGL10.EGL_DEPTH_SIZE, 0, + EGL10.EGL_STENCIL_SIZE, 0, + EGL10.EGL_NONE + }; + } + + @Override + protected void finalize() throws Throwable + { + super.finalize(); + running = false; + } + + + public interface OnGlReadyListener { + void onGlReady(); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/limelight/sbs/VideoTextureRenderer.java b/app/src/main/java/com/limelight/sbs/VideoTextureRenderer.java new file mode 100755 index 0000000000..7197fa52f7 --- /dev/null +++ b/app/src/main/java/com/limelight/sbs/VideoTextureRenderer.java @@ -0,0 +1,343 @@ +/* +By Ahmed Hilali + +A derivative work based on: http://github.com/izacus/AndroidOpenGLVideoDemo/ +See LICENSE.txt + */ + +package com.limelight.sbs; + +import android.content.Context; +import android.graphics.*; +import android.opengl.GLES11Ext; +import android.opengl.GLES20; +import android.opengl.GLUtils; +import android.util.Log; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.FloatBuffer; +import java.nio.ShortBuffer; + +public class VideoTextureRenderer extends TextureSurfaceRenderer implements SurfaceTexture.OnFrameAvailableListener +{ + private static final String vertexShaderCode = + "attribute vec4 vPosition;" + + "attribute vec4 vTexCoordinate;" + + "uniform mat4 textureTransform;" + + "varying vec2 v_TexCoordinate;" + + "void main() {" + + " v_TexCoordinate = (textureTransform * vTexCoordinate).xy;" + + " gl_Position = vPosition;" + + "}"; + + private static final String fragmentShaderCode = + "#extension GL_OES_EGL_image_external : require\n" + + "precision highp float;" + + "uniform samplerExternalOES texture;" + + "uniform float zoomFactor;" + + "uniform float distFactor;" + + "uniform float wrapEnabled;" + + "uniform float singleView;" + + "varying vec2 v_TexCoordinate;" + + " vec2 Warp(vec2 Tex)" + + " { " + + " vec2 newPos = Tex;" + + " float c = -distFactor/10.0;" + + " float zoomU = zoomFactor * 0.75;" + + " float u = Tex.x*zoomU - (zoomU / 2.0);" + + " float v = Tex.y*zoomFactor - (zoomFactor / 2.0);" + + " newPos.x = c*u/(pow(v, 2.0) + c);" + + " newPos.y = c*v/(pow(u, 2.0) + c);" + + " newPos.x = (newPos.x + 1.0)*0.5;" + + " newPos.y = (newPos.y + 1.0)*0.5;" + + " return newPos; " + + " } " + + + "void main () {" + + " if(singleView < 0.5) {" + + " vec2 newPos = v_TexCoordinate; " + + " if(newPos.x < 0.5) {" + + " newPos.x = newPos.x * 2.0;" + + " } else { " + + " newPos.x = (newPos.x - 0.5) * 2.0;" + + " } " + + " newPos = Warp(newPos);" + + " vec4 color = texture2D(texture, newPos);" + + " if(wrapEnabled < 0.5) {" + + " vec2 borderStep = step(0.0, newPos) * step(newPos, vec2(1.0, 1.0));" + + " color *= borderStep.x * borderStep.y;" + + " }" + + " gl_FragColor = color;" + + " } else {" + + " float squeezeFactor = (distFactor / 100.0);" + + " gl_FragColor = texture2D(texture, vec2(v_TexCoordinate.x, v_TexCoordinate.y * squeezeFactor - (squeezeFactor * 0.5)));" + + " }" + + "}"; + + private static float squareSize = 1.0f; + private static float squareCoords[] = { -squareSize, squareSize, 0.0f, // top left + -squareSize, -squareSize, 0.0f, // bottom left + squareSize, -squareSize, 0.0f, // bottom right + squareSize, squareSize, 0.0f }; // top right + + private static short drawOrder[] = { 0, 1, 2, 0, 2, 3}; + + private Context ctx; + + // Texture to be shown in backgrund + private FloatBuffer textureBuffer; + private float textureCoords[] = { 0.0f, 1.0f, 0.0f, 1.0f, + 0.0f, 0.0f, 0.0f, 1.0f, + 1.0f, 0.0f, 0.0f, 1.0f, + 1.0f, 1.0f, 0.0f, 1.0f }; + private int[] textures = new int[1]; + + private int vertexShaderHandle; + private int fragmentShaderHandle; + private int shaderProgram; + private FloatBuffer vertexBuffer; + private ShortBuffer drawListBuffer; + + private float zoomFactor = 3.2f; + private float distortionFactor = 81.0f; + private float wrapEnabled = 1.0f; + private float singleView = 0.0f; + private boolean zoomedIn = false; + + private SurfaceTexture videoTexture; + private float[] videoTextureTransform; + private boolean frameAvailable = false; + + private int videoWidth; + private int videoHeight; + private boolean adjustViewport = false; + + public VideoTextureRenderer(Context context, SurfaceTexture texture, int width, int height, OnGlReadyListener listener) + { + super(texture, width, height, listener); + this.ctx = context; + videoTextureTransform = new float[16]; + } + + + private void loadShaders() + { + vertexShaderHandle = GLES20.glCreateShader(GLES20.GL_VERTEX_SHADER); + GLES20.glShaderSource(vertexShaderHandle, vertexShaderCode); + GLES20.glCompileShader(vertexShaderHandle); + checkGlError("Vertex shader compile"); + + fragmentShaderHandle = GLES20.glCreateShader(GLES20.GL_FRAGMENT_SHADER); + GLES20.glShaderSource(fragmentShaderHandle, fragmentShaderCode); + GLES20.glCompileShader(fragmentShaderHandle); + checkGlError("Pixel shader compile"); + + shaderProgram = GLES20.glCreateProgram(); + GLES20.glAttachShader(shaderProgram, vertexShaderHandle); + GLES20.glAttachShader(shaderProgram, fragmentShaderHandle); + GLES20.glLinkProgram(shaderProgram); + checkGlError("Shader program compile"); + + int[] status = new int[1]; + GLES20.glGetProgramiv(shaderProgram, GLES20.GL_LINK_STATUS, status, 0); + if (status[0] != GLES20.GL_TRUE) { + String error = GLES20.glGetProgramInfoLog(shaderProgram); + Log.e("SurfaceTest", "Error while linking program:\n" + error); + } + + } + + public boolean isZoomedIn() { + return zoomedIn; + } + + public void setZoomedIn(boolean zoomedIn1) { + this.zoomedIn = zoomedIn1; + } + + private void setupVertexBuffer() + { + // Draw list buffer + ByteBuffer dlb = ByteBuffer.allocateDirect(drawOrder. length * 2); + dlb.order(ByteOrder.nativeOrder()); + drawListBuffer = dlb.asShortBuffer(); + drawListBuffer.put(drawOrder); + drawListBuffer.position(0); + + // Initialize the texture holder + ByteBuffer bb = ByteBuffer.allocateDirect(squareCoords.length * 4); + bb.order(ByteOrder.nativeOrder()); + + vertexBuffer = bb.asFloatBuffer(); + vertexBuffer.put(squareCoords); + vertexBuffer.position(0); + } + + + private void setupTexture(Context context) + { + ByteBuffer texturebb = ByteBuffer.allocateDirect(textureCoords.length * 4); + texturebb.order(ByteOrder.nativeOrder()); + + textureBuffer = texturebb.asFloatBuffer(); + textureBuffer.put(textureCoords); + textureBuffer.position(0); + + // Generate the actual texture + GLES20.glActiveTexture(GLES20.GL_TEXTURE0); + GLES20.glGenTextures(1, textures, 0); + checkGlError("Texture generate"); + + GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textures[0]); + checkGlError("Texture bind"); + + videoTexture = new SurfaceTexture(textures[0]); + videoTexture.setOnFrameAvailableListener(this); + } + + @Override + protected boolean draw() + { + synchronized (this) + { + if (frameAvailable) + { + videoTexture.updateTexImage(); + videoTexture.getTransformMatrix(videoTextureTransform); + frameAvailable = false; + } + else + { + return false; + } + + } + + if (adjustViewport) + adjustViewport(); + + GLES20.glClearColor(1.0f, 0.0f, 0.0f, 0.0f); + GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT); + + // Draw texture + GLES20.glUseProgram(shaderProgram); + int textureParamHandle = GLES20.glGetUniformLocation(shaderProgram, "texture"); + int textureCoordinateHandle = GLES20.glGetAttribLocation(shaderProgram, "vTexCoordinate"); + int positionHandle = GLES20.glGetAttribLocation(shaderProgram, "vPosition"); + int textureTranformHandle = GLES20.glGetUniformLocation(shaderProgram, "textureTransform"); + int zoomHandle = GLES20.glGetUniformLocation(shaderProgram, "zoomFactor"); + int distHandle = GLES20.glGetUniformLocation(shaderProgram, "distFactor"); + int wrapHandle = GLES20.glGetUniformLocation(shaderProgram, "wrapEnabled"); + int singleHandle = GLES20.glGetUniformLocation(shaderProgram, "singleView"); + + GLES20.glEnableVertexAttribArray(positionHandle); + GLES20.glVertexAttribPointer(positionHandle, 3, GLES20.GL_FLOAT, false, 4 * 3, vertexBuffer); + + GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textures[0]); + GLES20.glActiveTexture(GLES20.GL_TEXTURE0); + GLES20.glUniform1i(textureParamHandle, 0); + + GLES20.glEnableVertexAttribArray(textureCoordinateHandle); + GLES20.glVertexAttribPointer(textureCoordinateHandle, 4, GLES20.GL_FLOAT, false, 0, textureBuffer); + + GLES20.glUniformMatrix4fv(textureTranformHandle, 1, false, videoTextureTransform, 0); + + float realZoomFactor = zoomedIn ? (zoomFactor * 1.8f) : zoomFactor; + GLES20.glUniform1f(zoomHandle, realZoomFactor); + GLES20.glUniform1f(distHandle, distortionFactor); + GLES20.glUniform1f(wrapHandle, wrapEnabled); + GLES20.glUniform1f(singleHandle, singleView); + + GLES20.glDrawElements(GLES20.GL_TRIANGLES, drawOrder.length, GLES20.GL_UNSIGNED_SHORT, drawListBuffer); + GLES20.glDisableVertexAttribArray(positionHandle); + GLES20.glDisableVertexAttribArray(textureCoordinateHandle); + + return true; + } + + public void setZoomFactor(float zoomFactor1) { + this.zoomFactor = zoomFactor1 / 15.625f; + } + + public void setDistortionFactor(float distortionFactor1) { + this.distortionFactor = distortionFactor1; + } + + public void setWrapEnabled(boolean enabled) { + this.wrapEnabled = enabled ? 1.0f : 0.0f; + } + + public void setSingleView(boolean enabled) { + this.singleView = enabled ? 1.0f : 0.0f; + } + + private void adjustViewport() + { + float surfaceAspect = height / (float)width; + float videoAspect = videoHeight / (float)videoWidth; + + if (surfaceAspect > videoAspect) + { + float heightRatio = height / (float)videoHeight; + int newWidth = (int)(videoWidth * heightRatio); + int xOffset = (newWidth - width) / 2; + GLES20.glViewport(-xOffset, 0, newWidth, height); + } + else + { + float widthRatio = width / (float)videoWidth; + int newHeight = (int)(videoHeight * widthRatio); + int yOffset = (newHeight - height) / 2; + GLES20.glViewport(0, -yOffset, width, newHeight); + } + + adjustViewport = false; + } + + @Override + protected void initGLComponents() + { + setupVertexBuffer(); + setupTexture(ctx); + loadShaders(); + } + + @Override + protected void deinitGLComponents() + { + GLES20.glDeleteTextures(1, textures, 0); + GLES20.glDeleteProgram(shaderProgram); + videoTexture.release(); + videoTexture.setOnFrameAvailableListener(null); + } + + public void setVideoSize(int width, int height) + { + this.videoWidth = width; + this.videoHeight = height; + adjustViewport = true; + } + + public void checkGlError(String op) + { + int error; + while ((error = GLES20.glGetError()) != GLES20.GL_NO_ERROR) { + Log.e("SurfaceTest", op + ": glError " + GLUtils.getEGLErrorString(error)); + } + } + + public SurfaceTexture getVideoTexture() + { + return videoTexture; + } + + @Override + public void onFrameAvailable(SurfaceTexture surfaceTexture) + { + synchronized (this) + { + frameAvailable = true; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/limelight/ui/AdapterFragment.java b/app/src/main/java/com/limelight/ui/AdapterFragment.java old mode 100644 new mode 100755 index 8e1c7b9182..c808c7f472 --- a/app/src/main/java/com/limelight/ui/AdapterFragment.java +++ b/app/src/main/java/com/limelight/ui/AdapterFragment.java @@ -1,35 +1,35 @@ -package com.limelight.ui; - - -import android.app.Activity; -import android.app.Fragment; -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.AbsListView; - -import com.limelight.R; - -public class AdapterFragment extends Fragment { - private AdapterFragmentCallbacks callbacks; - - @Override - public void onAttach(Activity activity) { - super.onAttach(activity); - - callbacks = (AdapterFragmentCallbacks) activity; - } - - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, - Bundle savedInstanceState) { - return inflater.inflate(callbacks.getAdapterFragmentLayoutId(), container, false); - } - - @Override - public void onActivityCreated(Bundle savedInstanceState) { - super.onActivityCreated(savedInstanceState); - callbacks.receiveAbsListView(getView().findViewById(R.id.fragmentView)); - } -} +package com.limelight.ui; + + +import android.app.Activity; +import android.app.Fragment; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AbsListView; + +import com.limelight.R; + +public class AdapterFragment extends Fragment { + private AdapterFragmentCallbacks callbacks; + + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + + callbacks = (AdapterFragmentCallbacks) activity; + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + return inflater.inflate(callbacks.getAdapterFragmentLayoutId(), container, false); + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + callbacks.receiveAbsListView(getView().findViewById(R.id.fragmentView)); + } +} diff --git a/app/src/main/java/com/limelight/ui/AdapterFragmentCallbacks.java b/app/src/main/java/com/limelight/ui/AdapterFragmentCallbacks.java old mode 100644 new mode 100755 index 8a6db396df..630d1303c1 --- a/app/src/main/java/com/limelight/ui/AdapterFragmentCallbacks.java +++ b/app/src/main/java/com/limelight/ui/AdapterFragmentCallbacks.java @@ -1,8 +1,8 @@ -package com.limelight.ui; - -import android.widget.AbsListView; - -public interface AdapterFragmentCallbacks { - int getAdapterFragmentLayoutId(); - void receiveAbsListView(AbsListView gridView); -} +package com.limelight.ui; + +import android.widget.AbsListView; + +public interface AdapterFragmentCallbacks { + int getAdapterFragmentLayoutId(); + void receiveAbsListView(AbsListView gridView); +} diff --git a/app/src/main/java/com/limelight/ui/ApertureViewGroup.java b/app/src/main/java/com/limelight/ui/ApertureViewGroup.java new file mode 100755 index 0000000000..82bc29c98e --- /dev/null +++ b/app/src/main/java/com/limelight/ui/ApertureViewGroup.java @@ -0,0 +1,150 @@ +package com.limelight.ui; + +import android.animation.ObjectAnimator; +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.LinearGradient; +import android.graphics.Outline; +import android.graphics.Paint; +import android.graphics.RectF; +import android.graphics.Shader; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewOutlineProvider; +import android.widget.LinearLayout; + +import com.limelight.R; +import com.limelight.utils.UiHelper; + + +public class ApertureViewGroup extends LinearLayout { + + private int mColor1 = 0; + private int mColor2 = 0; + private float mBorderWidth = 0f; + private float mBorderAngle = 0f; + private int mDuration = 0; + private int mMiddleColor = 0; + + private Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); + private RectF rectF; + private LinearGradient color1; + private LinearGradient color2; + private ObjectAnimator animator; + private float currentSpeed = 0f; + + public ApertureViewGroup(Context context) { + this(context, null); + } + + public ApertureViewGroup(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public ApertureViewGroup(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(context, attrs); + } + + private void init(Context context, AttributeSet attrs) { + setOutlineProvider(new ViewOutlineProvider() { + @Override + public void getOutline(View view, Outline outline) { + outline.setRoundRect(0, 0, view.getWidth(), view.getHeight(), mBorderAngle); + } + }); + setClipToOutline(true); + + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ApertureViewGroup); + try { + mColor1 = a.getColor(R.styleable.ApertureViewGroup_aperture_color1, Color.YELLOW); + mColor2 = a.getColor(R.styleable.ApertureViewGroup_aperture_color2, -1); + mBorderWidth = a.getDimension(R.styleable.ApertureViewGroup_aperture_border_width, UiHelper.dpToPx(context, 20)); +// setPadding((int) mBorderWidth / 2,(int) mBorderWidth / 2,(int) mBorderWidth / 2,(int) mBorderWidth / 2); + mBorderAngle = a.getDimension(R.styleable.ApertureViewGroup_aperture_border_angle, UiHelper.dpToPx(context, 20)); + mDuration = a.getInt(R.styleable.ApertureViewGroup_aperture_duration, 3000); + mMiddleColor = a.getColor(R.styleable.ApertureViewGroup_aperture_middle_color, Color.BLACK); + } finally { + a.recycle(); + } + + animator = ObjectAnimator.ofFloat(this, "currentSpeed", 0f, 360f); + animator.setRepeatCount(ObjectAnimator.INFINITE); + animator.setRepeatMode(ObjectAnimator.RESTART); + animator.setInterpolator(null); + animator.setDuration(mDuration); + } + + public float getCurrentSpeed() { + return currentSpeed; + } + + public void setCurrentSpeed(float currentSpeed) { + this.currentSpeed = currentSpeed; + invalidate(); + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + if (rectF == null) { + float left = 0f + mBorderWidth / 2f; + float top = 0f + mBorderWidth / 2f; + float right = left + w - mBorderWidth; + float bottom = top + h - mBorderWidth; + rectF = new RectF(left, top, right, bottom); + } + + if (color1 == null) { + color1 = new LinearGradient( + w * 1f, h / 2f, + w * 1f, h * 1f, + new int[]{Color.TRANSPARENT, mColor1}, + new float[]{0f, 0.9f}, + Shader.TileMode.CLAMP + ); + } + + if (color2 == null && mColor2 != -1) { + color2 = new LinearGradient( + w / 2f, h / 2f, + w / 2f, 0f, + new int[]{Color.TRANSPARENT, mColor2}, + new float[]{0f, 0.9f}, + Shader.TileMode.CLAMP + ); + } + + animator.start(); + } + + @Override + protected void dispatchDraw(Canvas canvas) { + float left1 = getWidth() / 2f; + float top1 = getHeight() / 2f; + float right1 = left1 + getWidth(); + float bottom1 = top1 + getHeight(); + + canvas.save(); + canvas.rotate(currentSpeed, getWidth() / 2f, getHeight() / 2f); + + paint.setShader(color1); + canvas.drawRect(left1, top1, right1, bottom1, paint); + paint.setShader(null); + + if (mColor2 != -1) { + paint.setShader(color2); + canvas.drawRect(left1, top1, -right1, -bottom1, paint); + paint.setShader(null); + } + + paint.setColor(mMiddleColor); + canvas.drawRoundRect(rectF, mBorderAngle, mBorderAngle, paint); + + canvas.restore(); + + super.dispatchDraw(canvas); + } +} diff --git a/app/src/main/java/com/limelight/ui/GameGestures.java b/app/src/main/java/com/limelight/ui/GameGestures.java old mode 100644 new mode 100755 index 74dd7b056f..50cdf5139e --- a/app/src/main/java/com/limelight/ui/GameGestures.java +++ b/app/src/main/java/com/limelight/ui/GameGestures.java @@ -1,5 +1,9 @@ -package com.limelight.ui; - -public interface GameGestures { - void toggleKeyboard(); -} +package com.limelight.ui; + +import com.limelight.binding.input.GameInputDevice; + +public interface GameGestures { + void toggleKeyboard(); + + default void showGameMenu(GameInputDevice device){}; +} diff --git a/app/src/main/java/com/limelight/ui/SBSStreamView.java b/app/src/main/java/com/limelight/ui/SBSStreamView.java new file mode 100755 index 0000000000..8632e88706 --- /dev/null +++ b/app/src/main/java/com/limelight/ui/SBSStreamView.java @@ -0,0 +1,83 @@ +package com.limelight.ui; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.KeyEvent; +import android.view.TextureView; + + +public class SBSStreamView extends TextureView { + public SBSStreamView(Context context) { + super(context); + } + + public SBSStreamView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public SBSStreamView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + private double desiredAspectRatio; + private SBSStreamView.InputCallbacks inputCallbacks; + + public void setDesiredAspectRatio(double aspectRatio) { + this.desiredAspectRatio = aspectRatio; + } + + public void setInputCallbacks(SBSStreamView.InputCallbacks callbacks) { + this.inputCallbacks = callbacks; + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + // If no fixed aspect ratio has been provided, simply use the default onMeasure() behavior + if (desiredAspectRatio == 0) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + return; + } + + // Based on code from: https://www.buzzingandroid.com/2012/11/easy-measuring-of-custom-views-with-specific-aspect-ratio/ + int widthSize = MeasureSpec.getSize(widthMeasureSpec); + int heightSize = MeasureSpec.getSize(heightMeasureSpec); + + int measuredHeight, measuredWidth; + if (widthSize > heightSize * desiredAspectRatio) { + measuredHeight = heightSize; + measuredWidth = (int)(measuredHeight * desiredAspectRatio); + } else { + measuredWidth = widthSize; + measuredHeight = (int)(measuredWidth / desiredAspectRatio); + } + + setMeasuredDimension(measuredWidth, measuredHeight); + } + + @Override + public boolean onKeyPreIme(int keyCode, KeyEvent event) { + // This callbacks allows us to override dumb IME behavior like when + // Samsung's default keyboard consumes Shift+Space. + if (inputCallbacks != null) { + if (event.getAction() == KeyEvent.ACTION_DOWN) { + if (inputCallbacks.handleKeyDown(event)) { + return true; + } + } + else if (event.getAction() == KeyEvent.ACTION_UP) { + if (inputCallbacks.handleKeyUp(event)) { + return true; + } + } + } + + return super.onKeyPreIme(keyCode, event); + } + + public interface InputCallbacks { + boolean handleKeyUp(KeyEvent event); + boolean handleKeyDown(KeyEvent event); + } + + +} diff --git a/app/src/main/java/com/limelight/ui/StreamView.java b/app/src/main/java/com/limelight/ui/StreamView.java old mode 100644 new mode 100755 index a11b416684..fa0cea14dc --- a/app/src/main/java/com/limelight/ui/StreamView.java +++ b/app/src/main/java/com/limelight/ui/StreamView.java @@ -1,85 +1,85 @@ -package com.limelight.ui; - -import android.annotation.TargetApi; -import android.content.Context; -import android.util.AttributeSet; -import android.view.KeyEvent; -import android.view.SurfaceView; - -public class StreamView extends SurfaceView { - private double desiredAspectRatio; - private InputCallbacks inputCallbacks; - - public void setDesiredAspectRatio(double aspectRatio) { - this.desiredAspectRatio = aspectRatio; - } - - public void setInputCallbacks(InputCallbacks callbacks) { - this.inputCallbacks = callbacks; - } - - public StreamView(Context context) { - super(context); - } - - public StreamView(Context context, AttributeSet attrs) { - super(context, attrs); - } - - public StreamView(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - } - - public StreamView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { - super(context, attrs, defStyleAttr, defStyleRes); - } - - @Override - protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { - // If no fixed aspect ratio has been provided, simply use the default onMeasure() behavior - if (desiredAspectRatio == 0) { - super.onMeasure(widthMeasureSpec, heightMeasureSpec); - return; - } - - // Based on code from: https://www.buzzingandroid.com/2012/11/easy-measuring-of-custom-views-with-specific-aspect-ratio/ - int widthSize = MeasureSpec.getSize(widthMeasureSpec); - int heightSize = MeasureSpec.getSize(heightMeasureSpec); - - int measuredHeight, measuredWidth; - if (widthSize > heightSize * desiredAspectRatio) { - measuredHeight = heightSize; - measuredWidth = (int)(measuredHeight * desiredAspectRatio); - } else { - measuredWidth = widthSize; - measuredHeight = (int)(measuredWidth / desiredAspectRatio); - } - - setMeasuredDimension(measuredWidth, measuredHeight); - } - - @Override - public boolean onKeyPreIme(int keyCode, KeyEvent event) { - // This callbacks allows us to override dumb IME behavior like when - // Samsung's default keyboard consumes Shift+Space. - if (inputCallbacks != null) { - if (event.getAction() == KeyEvent.ACTION_DOWN) { - if (inputCallbacks.handleKeyDown(event)) { - return true; - } - } - else if (event.getAction() == KeyEvent.ACTION_UP) { - if (inputCallbacks.handleKeyUp(event)) { - return true; - } - } - } - - return super.onKeyPreIme(keyCode, event); - } - - public interface InputCallbacks { - boolean handleKeyUp(KeyEvent event); - boolean handleKeyDown(KeyEvent event); - } -} +package com.limelight.ui; + +import android.annotation.TargetApi; +import android.content.Context; +import android.util.AttributeSet; +import android.view.KeyEvent; +import android.view.SurfaceView; + +public class StreamView extends SurfaceView { + private double desiredAspectRatio; + private InputCallbacks inputCallbacks; + + public void setDesiredAspectRatio(double aspectRatio) { + this.desiredAspectRatio = aspectRatio; + } + + public void setInputCallbacks(InputCallbacks callbacks) { + this.inputCallbacks = callbacks; + } + + public StreamView(Context context) { + super(context); + } + + public StreamView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public StreamView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public StreamView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + // If no fixed aspect ratio has been provided, simply use the default onMeasure() behavior + if (desiredAspectRatio == 0) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + return; + } + + // Based on code from: https://www.buzzingandroid.com/2012/11/easy-measuring-of-custom-views-with-specific-aspect-ratio/ + int widthSize = MeasureSpec.getSize(widthMeasureSpec); + int heightSize = MeasureSpec.getSize(heightMeasureSpec); + + int measuredHeight, measuredWidth; + if (widthSize > heightSize * desiredAspectRatio) { + measuredHeight = heightSize; + measuredWidth = (int)(measuredHeight * desiredAspectRatio); + } else { + measuredWidth = widthSize; + measuredHeight = (int)(measuredWidth / desiredAspectRatio); + } + + setMeasuredDimension(measuredWidth, measuredHeight); + } + + @Override + public boolean onKeyPreIme(int keyCode, KeyEvent event) { + // This callbacks allows us to override dumb IME behavior like when + // Samsung's default keyboard consumes Shift+Space. + if (inputCallbacks != null) { + if (event.getAction() == KeyEvent.ACTION_DOWN) { + if (inputCallbacks.handleKeyDown(event)) { + return true; + } + } + else if (event.getAction() == KeyEvent.ACTION_UP) { + if (inputCallbacks.handleKeyUp(event)) { + return true; + } + } + } + + return super.onKeyPreIme(keyCode, event); + } + + public interface InputCallbacks { + boolean handleKeyUp(KeyEvent event); + boolean handleKeyDown(KeyEvent event); + } +} diff --git a/app/src/main/java/com/limelight/utils/CacheHelper.java b/app/src/main/java/com/limelight/utils/CacheHelper.java old mode 100644 new mode 100755 index 4d265e185b..014f4bd7b4 --- a/app/src/main/java/com/limelight/utils/CacheHelper.java +++ b/app/src/main/java/com/limelight/utils/CacheHelper.java @@ -1,86 +1,86 @@ -package com.limelight.utils; - -import java.io.BufferedInputStream; -import java.io.BufferedOutputStream; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.OutputStream; -import java.io.Reader; - -public class CacheHelper { - public static File openPath(boolean createPath, File root, String... path) { - File f = root; - for (int i = 0; i < path.length; i++) { - String component = path[i]; - - if (i == path.length - 1) { - // This is the file component so now we create parent directories - if (createPath) { - f.mkdirs(); - } - } - - f = new File(f, component); - } - return f; - } - - public static long getFileSize(File root, String... path) { - return openPath(false, root, path).length(); - } - - public static boolean deleteCacheFile(File root, String... path) { - return openPath(false, root, path).delete(); - } - - public static boolean cacheFileExists(File root, String... path) { - return openPath(false, root, path).exists(); - } - - public static InputStream openCacheFileForInput(File root, String... path) throws FileNotFoundException { - return new BufferedInputStream(new FileInputStream(openPath(false, root, path))); - } - - public static OutputStream openCacheFileForOutput(File root, String... path) throws FileNotFoundException { - return new BufferedOutputStream(new FileOutputStream(openPath(true, root, path))); - } - - public static void writeInputStreamToOutputStream(InputStream in, OutputStream out, long maxLength) throws IOException { - byte[] buf = new byte[4096]; - int bytesRead; - - while ((bytesRead = in.read(buf)) != -1) { - maxLength -= bytesRead; - if (maxLength <= 0) { - throw new IOException("Stream exceeded max size"); - } - out.write(buf, 0, bytesRead); - } - } - - public static String readInputStreamToString(InputStream in) throws IOException { - Reader r = new InputStreamReader(in); - - StringBuilder sb = new StringBuilder(); - char[] buf = new char[256]; - int bytesRead; - while ((bytesRead = r.read(buf)) != -1) { - sb.append(buf, 0, bytesRead); - } - - try { - in.close(); - } catch (IOException ignored) {} - - return sb.toString(); - } - - public static void writeStringToOutputStream(OutputStream out, String str) throws IOException { - out.write(str.getBytes("UTF-8")); - } -} +package com.limelight.utils; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.Reader; + +public class CacheHelper { + public static File openPath(boolean createPath, File root, String... path) { + File f = root; + for (int i = 0; i < path.length; i++) { + String component = path[i]; + + if (i == path.length - 1) { + // This is the file component so now we create parent directories + if (createPath) { + f.mkdirs(); + } + } + + f = new File(f, component); + } + return f; + } + + public static long getFileSize(File root, String... path) { + return openPath(false, root, path).length(); + } + + public static boolean deleteCacheFile(File root, String... path) { + return openPath(false, root, path).delete(); + } + + public static boolean cacheFileExists(File root, String... path) { + return openPath(false, root, path).exists(); + } + + public static InputStream openCacheFileForInput(File root, String... path) throws FileNotFoundException { + return new BufferedInputStream(new FileInputStream(openPath(false, root, path))); + } + + public static OutputStream openCacheFileForOutput(File root, String... path) throws FileNotFoundException { + return new BufferedOutputStream(new FileOutputStream(openPath(true, root, path))); + } + + public static void writeInputStreamToOutputStream(InputStream in, OutputStream out, long maxLength) throws IOException { + byte[] buf = new byte[4096]; + int bytesRead; + + while ((bytesRead = in.read(buf)) != -1) { + maxLength -= bytesRead; + if (maxLength <= 0) { + throw new IOException("Stream exceeded max size"); + } + out.write(buf, 0, bytesRead); + } + } + + public static String readInputStreamToString(InputStream in) throws IOException { + Reader r = new InputStreamReader(in); + + StringBuilder sb = new StringBuilder(); + char[] buf = new char[256]; + int bytesRead; + while ((bytesRead = r.read(buf)) != -1) { + sb.append(buf, 0, bytesRead); + } + + try { + in.close(); + } catch (IOException ignored) {} + + return sb.toString(); + } + + public static void writeStringToOutputStream(OutputStream out, String str) throws IOException { + out.write(str.getBytes("UTF-8")); + } +} diff --git a/app/src/main/java/com/limelight/utils/DeviceUtils.java b/app/src/main/java/com/limelight/utils/DeviceUtils.java new file mode 100755 index 0000000000..ce95e8909c --- /dev/null +++ b/app/src/main/java/com/limelight/utils/DeviceUtils.java @@ -0,0 +1,410 @@ +package com.limelight.utils; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.Intent; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.net.Uri; +import android.net.wifi.WifiInfo; +import android.net.wifi.WifiManager; +import android.os.Build; +import android.provider.Settings; +import android.support.annotation.RequiresApi; +import android.support.annotation.RequiresPermission; +import android.telephony.TelephonyManager; +import android.text.TextUtils; +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.InetAddress; +import java.net.NetworkInterface; +import java.net.SocketException; +import java.util.Enumeration; +import static android.Manifest.permission.ACCESS_WIFI_STATE; +import static android.Manifest.permission.CHANGE_WIFI_STATE; +import static android.content.Context.WIFI_SERVICE; + +public final class DeviceUtils { + + private DeviceUtils() { + throw new UnsupportedOperationException("u can't instantiate me..."); + } + + /** + * Return whether device is rooted. + * + * @return {@code true}: yes
{@code false}: no + */ + public static boolean isDeviceRooted() { + String su = "su"; + String[] locations = {"/system/bin/", "/system/xbin/", "/sbin/", "/system/sd/xbin/", + "/system/bin/failsafe/", "/data/local/xbin/", "/data/local/bin/", "/data/local/", + "/system/sbin/", "/usr/bin/", "/vendor/bin/"}; + for (String location : locations) { + if (new File(location + su).exists()) { + return true; + } + } + return false; + } + + /** + * Return whether ADB is enabled. + * + * @return {@code true}: yes
{@code false}: no + */ + @RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN_MR1) + public static boolean isAdbEnabled(Context context) { + return Settings.Secure.getInt( + context.getContentResolver(), + Settings.Global.ADB_ENABLED, 0 + ) > 0; + } + + /** + * Return the version name of device's system. + * + * @return the version name of device's system + */ + public static String getSDKVersionName() { + return android.os.Build.VERSION.RELEASE; + } + + /** + * Return version code of device's system. + * + * @return version code of device's system + */ + public static int getSDKVersionCode() { + return android.os.Build.VERSION.SDK_INT; + } + + /** + * Return the android id of device. + * + * @return the android id of device + */ + @SuppressLint("HardwareIds") + public static String getAndroidID(Context context) { + String id = Settings.Secure.getString( + context.getContentResolver(), + Settings.Secure.ANDROID_ID + ); + if ("9774d56d682e549c".equals(id)) return ""; + return id == null ? "" : id; + } + + /** + * Return the MAC address. + *

Must hold {@code }, + * {@code }, + * {@code }

+ * + * @return the MAC address + */ + @RequiresPermission(allOf = {ACCESS_WIFI_STATE, CHANGE_WIFI_STATE}) + public static String getMacAddress(Context context) { + String macAddress = getMacAddress(context,(String[]) null); + if (!TextUtils.isEmpty(macAddress) || getWifiEnabled(context)) return macAddress; + setWifiEnabled(context,true); + setWifiEnabled(context,false); + return getMacAddress(context,(String[]) null); + } + + private static boolean getWifiEnabled(Context context) { + @SuppressLint("WifiManagerLeak") + WifiManager manager = (WifiManager) context.getSystemService(WIFI_SERVICE); + if (manager == null) return false; + return manager.isWifiEnabled(); + } + + /** + * Enable or disable wifi. + *

Must hold {@code }

+ * + * @param enabled True to enabled, false otherwise. + */ + @RequiresPermission(CHANGE_WIFI_STATE) + private static void setWifiEnabled(Context context,final boolean enabled) { + @SuppressLint("WifiManagerLeak") + WifiManager manager = (WifiManager) context.getSystemService(WIFI_SERVICE); + if (manager == null) return; + if (enabled == manager.isWifiEnabled()) return; + manager.setWifiEnabled(enabled); + } + + /** + * Return the MAC address. + *

Must hold {@code }, + * {@code }

+ * + * @return the MAC address + */ + @RequiresPermission(allOf = {ACCESS_WIFI_STATE}) + public static String getMacAddress(Context context,final String... excepts) { + String macAddress = getMacAddressByNetworkInterface(); + if (isAddressNotInExcepts(macAddress, excepts)) { + return macAddress; + } + macAddress = getMacAddressByInetAddress(); + if (isAddressNotInExcepts(macAddress, excepts)) { + return macAddress; + } + macAddress = getMacAddressByWifiInfo(context); + if (isAddressNotInExcepts(macAddress, excepts)) { + return macAddress; + } + return ""; + } + + private static boolean isAddressNotInExcepts(final String address, final String... excepts) { + if (TextUtils.isEmpty(address)) { + return false; + } + if ("02:00:00:00:00:00".equals(address)) { + return false; + } + if (excepts == null || excepts.length == 0) { + return true; + } + for (String filter : excepts) { + if (filter != null && filter.equals(address)) { + return false; + } + } + return true; + } + + @RequiresPermission(ACCESS_WIFI_STATE) + private static String getMacAddressByWifiInfo(Context context) { + try { + final WifiManager wifi = (WifiManager) context + .getApplicationContext().getSystemService(WIFI_SERVICE); + if (wifi != null) { + final WifiInfo info = wifi.getConnectionInfo(); + if (info != null) { + @SuppressLint("HardwareIds") + String macAddress = info.getMacAddress(); + if (!TextUtils.isEmpty(macAddress)) { + return macAddress; + } + } + } + } catch (Exception e) { + e.printStackTrace(); + } + return "02:00:00:00:00:00"; + } + + private static String getMacAddressByNetworkInterface() { + try { + Enumeration nis = NetworkInterface.getNetworkInterfaces(); + while (nis.hasMoreElements()) { + NetworkInterface ni = nis.nextElement(); + if (ni == null || !ni.getName().equalsIgnoreCase("wlan0")) continue; + byte[] macBytes = ni.getHardwareAddress(); + if (macBytes != null && macBytes.length > 0) { + StringBuilder sb = new StringBuilder(); + for (byte b : macBytes) { + sb.append(String.format("%02x:", b)); + } + return sb.substring(0, sb.length() - 1); + } + } + } catch (Exception e) { + e.printStackTrace(); + } + return "02:00:00:00:00:00"; + } + + private static String getMacAddressByInetAddress() { + try { + InetAddress inetAddress = getInetAddress(); + if (inetAddress != null) { + NetworkInterface ni = NetworkInterface.getByInetAddress(inetAddress); + if (ni != null) { + byte[] macBytes = ni.getHardwareAddress(); + if (macBytes != null && macBytes.length > 0) { + StringBuilder sb = new StringBuilder(); + for (byte b : macBytes) { + sb.append(String.format("%02x:", b)); + } + return sb.substring(0, sb.length() - 1); + } + } + } + } catch (Exception e) { + e.printStackTrace(); + } + return "02:00:00:00:00:00"; + } + + private static InetAddress getInetAddress() { + try { + Enumeration nis = NetworkInterface.getNetworkInterfaces(); + while (nis.hasMoreElements()) { + NetworkInterface ni = nis.nextElement(); + // To prevent phone of xiaomi return "10.0.2.15" + if (!ni.isUp()) continue; + Enumeration addresses = ni.getInetAddresses(); + while (addresses.hasMoreElements()) { + InetAddress inetAddress = addresses.nextElement(); + if (!inetAddress.isLoopbackAddress()) { + String hostAddress = inetAddress.getHostAddress(); + if (hostAddress.indexOf(':') < 0) return inetAddress; + } + } + } + } catch (SocketException e) { + e.printStackTrace(); + } + return null; + } + + /** + * Return the manufacturer of the product/hardware. + *

e.g. Xiaomi

+ * + * @return the manufacturer of the product/hardware + */ + public static String getManufacturer() { + return Build.MANUFACTURER; + } + + /** + * Return the model of device. + *

e.g. MI2SC

+ * + * @return the model of device + */ + public static String getModel() { + String model = Build.MODEL; + if (model != null) { + model = model.trim().replaceAll("\\s*", ""); + } else { + model = ""; + } + return model; + } + + /** + * Return an ordered list of ABIs supported by this device. The most preferred ABI is the first + * element in the list. + * + * @return an ordered list of ABIs supported by this device + */ + public static String[] getABIs() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + return Build.SUPPORTED_ABIS; + } else { + if (!TextUtils.isEmpty(Build.CPU_ABI2)) { + return new String[]{Build.CPU_ABI, Build.CPU_ABI2}; + } + return new String[]{Build.CPU_ABI}; + } + } + + /** + * Return whether device is tablet. + * + * @return {@code true}: yes
{@code false}: no + */ + public static boolean isTablet() { + return (Resources.getSystem().getConfiguration().screenLayout + & Configuration.SCREENLAYOUT_SIZE_MASK) + >= Configuration.SCREENLAYOUT_SIZE_LARGE; + } + + /** + * Return whether device is emulator. + * + * @return {@code true}: yes
{@code false}: no + */ + public static boolean isEmulator(Context context) { + boolean checkProperty = Build.FINGERPRINT.startsWith("generic") + || Build.FINGERPRINT.toLowerCase().contains("vbox") + || Build.FINGERPRINT.toLowerCase().contains("test-keys") + || Build.MODEL.contains("google_sdk") + || Build.MODEL.contains("Emulator") + || Build.MODEL.contains("Android SDK built for x86") + || Build.MANUFACTURER.contains("Genymotion") + || (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")) + || "google_sdk".equals(Build.PRODUCT); + if (checkProperty) return true; + + String operatorName = ""; + TelephonyManager tm = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); + if (tm != null) { + String name = tm.getNetworkOperatorName(); + if (name != null) { + operatorName = name; + } + } + boolean checkOperatorName = operatorName.toLowerCase().equals("android"); + if (checkOperatorName) return true; + + String url = "tel:" + "123456"; + Intent intent = new Intent(); + intent.setData(Uri.parse(url)); + intent.setAction(Intent.ACTION_DIAL); + boolean checkDial = intent.resolveActivity(context.getPackageManager()) == null; + if (checkDial) return true; + if (isEmulatorByCpu()) return true; + +// boolean checkDebuggerConnected = Debug.isDebuggerConnected(); +// if (checkDebuggerConnected) return true; + + return false; + } + + /** + * Returns whether is emulator by check cpu info. + * by function of {@link #readCpuInfo}, obtain the device cpu information. + * then compare whether it is intel or amd (because intel and amd are generally not mobile phone cpu), to determine whether it is a real mobile phone + * + * @return {@code true}: yes
{@code false}: no + */ + private static boolean isEmulatorByCpu() { + String cpuInfo = readCpuInfo(); + return cpuInfo.contains("intel") || cpuInfo.contains("amd"); + } + + /** + * Return Cpu information + * + * @return Cpu info + */ + public static String readCpuInfo() { + String result = ""; + try { + String[] args = {"/system/bin/cat", "/proc/cpuinfo"}; + ProcessBuilder cmd = new ProcessBuilder(args); + Process process = cmd.start(); + StringBuilder sb = new StringBuilder(); + String readLine; + BufferedReader responseReader = new BufferedReader(new InputStreamReader(process.getInputStream(), "utf-8")); + while ((readLine = responseReader.readLine()) != null) { + sb.append(readLine); + } + responseReader.close(); + result = sb.toString().toLowerCase(); + } catch (IOException ignored) { + } + return result; + } + + /** + * Whether user has enabled development settings. + * + * @return whether user has enabled development settings. + */ + @RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN_MR1) + public static boolean isDevelopmentSettingsEnabled(Context context) { + return Settings.Global.getInt( + context.getContentResolver(), + Settings.Global.DEVELOPMENT_SETTINGS_ENABLED, 0 + ) > 0; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/limelight/utils/Dialog.java b/app/src/main/java/com/limelight/utils/Dialog.java old mode 100644 new mode 100755 index 7b3f9fd7dc..93ce78b619 --- a/app/src/main/java/com/limelight/utils/Dialog.java +++ b/app/src/main/java/com/limelight/utils/Dialog.java @@ -1,113 +1,113 @@ -package com.limelight.utils; - -import java.util.ArrayList; - -import android.app.Activity; -import android.app.AlertDialog; -import android.content.DialogInterface; -import android.widget.Button; - -import com.limelight.R; - -public class Dialog implements Runnable { - private final String title; - private final String message; - private final Activity activity; - private final Runnable runOnDismiss; - - private AlertDialog alert; - - private static final ArrayList rundownDialogs = new ArrayList<>(); - - private Dialog(Activity activity, String title, String message, Runnable runOnDismiss) - { - this.activity = activity; - this.title = title; - this.message = message; - this.runOnDismiss = runOnDismiss; - } - - public static void closeDialogs() - { - synchronized (rundownDialogs) { - for (Dialog d : rundownDialogs) { - if (d.alert.isShowing()) { - d.alert.dismiss(); - } - } - - rundownDialogs.clear(); - } - } - - public static void displayDialog(final Activity activity, String title, String message, final boolean endAfterDismiss) - { - activity.runOnUiThread(new Dialog(activity, title, message, new Runnable() { - @Override - public void run() { - if (endAfterDismiss) { - activity.finish(); - } - } - })); - } - - public static void displayDialog(Activity activity, String title, String message, Runnable runOnDismiss) - { - activity.runOnUiThread(new Dialog(activity, title, message, runOnDismiss)); - } - - @Override - public void run() { - // If we're dying, don't bother creating a dialog - if (activity.isFinishing()) - return; - - alert = new AlertDialog.Builder(activity).create(); - - alert.setTitle(title); - alert.setMessage(message); - alert.setCancelable(false); - alert.setCanceledOnTouchOutside(false); - - alert.setButton(AlertDialog.BUTTON_POSITIVE, activity.getResources().getText(android.R.string.ok), new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int which) { - synchronized (rundownDialogs) { - rundownDialogs.remove(Dialog.this); - alert.dismiss(); - } - - runOnDismiss.run(); - } - }); - alert.setButton(AlertDialog.BUTTON_NEUTRAL, activity.getResources().getText(R.string.help), new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int which) { - synchronized (rundownDialogs) { - rundownDialogs.remove(Dialog.this); - alert.dismiss(); - } - - runOnDismiss.run(); - - HelpLauncher.launchTroubleshooting(activity); - } - }); - alert.setOnShowListener(new DialogInterface.OnShowListener(){ - - @Override - public void onShow(DialogInterface dialog) { - // Set focus to the OK button by default - Button button = alert.getButton(AlertDialog.BUTTON_POSITIVE); - button.setFocusable(true); - button.setFocusableInTouchMode(true); - button.requestFocus(); - } - }); - - synchronized (rundownDialogs) { - rundownDialogs.add(this); - alert.show(); - } - } - -} +package com.limelight.utils; + +import java.util.ArrayList; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.DialogInterface; +import android.widget.Button; + +import com.limelight.R; + +public class Dialog implements Runnable { + private final String title; + private final String message; + private final Activity activity; + private final Runnable runOnDismiss; + + private AlertDialog alert; + + private static final ArrayList rundownDialogs = new ArrayList<>(); + + private Dialog(Activity activity, String title, String message, Runnable runOnDismiss) + { + this.activity = activity; + this.title = title; + this.message = message; + this.runOnDismiss = runOnDismiss; + } + + public static void closeDialogs() + { + synchronized (rundownDialogs) { + for (Dialog d : rundownDialogs) { + if (d.alert.isShowing()) { + d.alert.dismiss(); + } + } + + rundownDialogs.clear(); + } + } + + public static void displayDialog(final Activity activity, String title, String message, final boolean endAfterDismiss) + { + activity.runOnUiThread(new Dialog(activity, title, message, new Runnable() { + @Override + public void run() { + if (endAfterDismiss) { + activity.finish(); + } + } + })); + } + + public static void displayDialog(Activity activity, String title, String message, Runnable runOnDismiss) + { + activity.runOnUiThread(new Dialog(activity, title, message, runOnDismiss)); + } + + @Override + public void run() { + // If we're dying, don't bother creating a dialog + if (activity.isFinishing()) + return; + + alert = new AlertDialog.Builder(activity).create(); + + alert.setTitle(title); + alert.setMessage(message); + alert.setCancelable(false); + alert.setCanceledOnTouchOutside(false); + + alert.setButton(AlertDialog.BUTTON_POSITIVE, activity.getResources().getText(android.R.string.ok), new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int which) { + synchronized (rundownDialogs) { + rundownDialogs.remove(Dialog.this); + alert.dismiss(); + } + + runOnDismiss.run(); + } + }); + alert.setButton(AlertDialog.BUTTON_NEUTRAL, activity.getResources().getText(R.string.help), new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int which) { + synchronized (rundownDialogs) { + rundownDialogs.remove(Dialog.this); + alert.dismiss(); + } + + runOnDismiss.run(); + + HelpLauncher.launchTroubleshooting(activity); + } + }); + alert.setOnShowListener(new DialogInterface.OnShowListener(){ + + @Override + public void onShow(DialogInterface dialog) { + // Set focus to the OK button by default + Button button = alert.getButton(AlertDialog.BUTTON_POSITIVE); + button.setFocusable(true); + button.setFocusableInTouchMode(true); + button.requestFocus(); + } + }); + + synchronized (rundownDialogs) { + rundownDialogs.add(this); + alert.show(); + } + } + +} diff --git a/app/src/main/java/com/limelight/utils/FileUriUtils.java b/app/src/main/java/com/limelight/utils/FileUriUtils.java new file mode 100755 index 0000000000..8c2e6e05e7 --- /dev/null +++ b/app/src/main/java/com/limelight/utils/FileUriUtils.java @@ -0,0 +1,102 @@ +package com.limelight.utils; + +import android.content.Context; +import android.net.Uri; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.Reader; + +/** + * Description + * Date: 2024-03-20 + * Time: 13:46 + */ +public class FileUriUtils { + public static String openUriForRead(Context context, Uri uri) { + if (uri == null) + return ""; + InputStream inputStream = null; + Reader reader = null; + BufferedReader bufferedReader = null; + StringBuilder result = new StringBuilder(); + try { + inputStream = context.getContentResolver().openInputStream(uri); + reader = new InputStreamReader(inputStream); + bufferedReader = new BufferedReader(reader); + String temp; + while ((temp = bufferedReader.readLine()) != null) { + result.append(temp); + } + } catch (Exception e) { + e.printStackTrace(); + } finally { + if (reader != null) { + try { + reader.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + if (inputStream != null) { + try { + inputStream.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + if (bufferedReader != null) { + try { + bufferedReader.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + } + return result.toString(); + } + + public static boolean openUriForWrite(Context context, Uri uri, String content) { + if (uri == null) { + return false; + } + + try { + //从uri构造输出流 + OutputStream outputStream = context.getContentResolver().openOutputStream(uri); + //写入文件 + outputStream.write(content.getBytes()); + outputStream.flush(); + outputStream.close(); + return true; + } catch (Exception e) { + e.getLocalizedMessage(); + } + return false; + } + + public static boolean writerFileString(File file, String content) { + FileOutputStream fileOutputStream = null; + try { + fileOutputStream = new FileOutputStream(file); + fileOutputStream.write(content.getBytes()); + } catch (Exception e) { + e.printStackTrace(); + return false; + } finally { + if (fileOutputStream != null) { + try { + fileOutputStream.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + } + return true; + } +} diff --git a/app/src/main/java/com/limelight/utils/HelpLauncher.java b/app/src/main/java/com/limelight/utils/HelpLauncher.java old mode 100644 new mode 100755 index cae80cdde8..f2b86c5b25 --- a/app/src/main/java/com/limelight/utils/HelpLauncher.java +++ b/app/src/main/java/com/limelight/utils/HelpLauncher.java @@ -1,51 +1,51 @@ -package com.limelight.utils; - -import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.net.Uri; - -import com.limelight.HelpActivity; - -public class HelpLauncher { - public static void launchUrl(Context context, String url) { - // Try to launch the default browser - try { - Intent i = new Intent(Intent.ACTION_VIEW); - i.setData(Uri.parse(url)); - - // Several Android TV devices will lie and say they do have a browser even though the OS - // just shows an error dialog if we try to use it. We used to try to be clever and check - // the package name of the resolved intent, but it's not worth it anymore with Android 11's - // package visibility changes. We'll just always use the WebView on Android TV. - if (!context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_LEANBACK)) { - context.startActivity(i); - return; - } - } catch (Exception e) { - // This is only supposed to throw ActivityNotFoundException but - // it can (at least) also throw SecurityException if a user's default - // browser is not exported. We'll catch everything to workaround this. - - // Fall through - } - - // This platform has no browser (possibly a leanback device) - // We'll launch our WebView activity - Intent i = new Intent(context, HelpActivity.class); - i.setData(Uri.parse(url)); - context.startActivity(i); - } - - public static void launchSetupGuide(Context context) { - launchUrl(context, "https://github.com/moonlight-stream/moonlight-docs/wiki/Setup-Guide"); - } - - public static void launchTroubleshooting(Context context) { - launchUrl(context, "https://github.com/moonlight-stream/moonlight-docs/wiki/Troubleshooting"); - } - - public static void launchGameStreamEolFaq(Context context) { - launchUrl(context, "https://github.com/moonlight-stream/moonlight-docs/wiki/NVIDIA-GameStream-End-Of-Service-Announcement-FAQ"); - } -} +package com.limelight.utils; + +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.net.Uri; + +import com.limelight.HelpActivity; + +public class HelpLauncher { + public static void launchUrl(Context context, String url) { + // Try to launch the default browser + try { + Intent i = new Intent(Intent.ACTION_VIEW); + i.setData(Uri.parse(url)); + + // Several Android TV devices will lie and say they do have a browser even though the OS + // just shows an error dialog if we try to use it. We used to try to be clever and check + // the package name of the resolved intent, but it's not worth it anymore with Android 11's + // package visibility changes. We'll just always use the WebView on Android TV. + if (!context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_LEANBACK)) { + context.startActivity(i); + return; + } + } catch (Exception e) { + // This is only supposed to throw ActivityNotFoundException but + // it can (at least) also throw SecurityException if a user's default + // browser is not exported. We'll catch everything to workaround this. + + // Fall through + } + + // This platform has no browser (possibly a leanback device) + // We'll launch our WebView activity + Intent i = new Intent(context, HelpActivity.class); + i.setData(Uri.parse(url)); + context.startActivity(i); + } + + public static void launchSetupGuide(Context context) { + launchUrl(context, "https://github.com/moonlight-stream/moonlight-docs/wiki/Setup-Guide"); + } + + public static void launchTroubleshooting(Context context) { + launchUrl(context, "https://github.com/moonlight-stream/moonlight-docs/wiki/Troubleshooting"); + } + + public static void launchGameStreamEolFaq(Context context) { + launchUrl(context, "https://github.com/moonlight-stream/moonlight-docs/wiki/NVIDIA-GameStream-End-Of-Service-Announcement-FAQ"); + } +} diff --git a/app/src/main/java/com/limelight/utils/NetHelper.java b/app/src/main/java/com/limelight/utils/NetHelper.java old mode 100644 new mode 100755 index eb5512f8b6..d025bf271c --- a/app/src/main/java/com/limelight/utils/NetHelper.java +++ b/app/src/main/java/com/limelight/utils/NetHelper.java @@ -1,32 +1,32 @@ -package com.limelight.utils; - -import android.content.Context; -import android.net.ConnectivityManager; -import android.net.Network; -import android.net.NetworkCapabilities; -import android.net.NetworkInfo; -import android.os.Build; - -public class NetHelper { - public static boolean isActiveNetworkVpn(Context context) { - ConnectivityManager connMgr = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - Network activeNetwork = connMgr.getActiveNetwork(); - if (activeNetwork != null) { - NetworkCapabilities netCaps = connMgr.getNetworkCapabilities(activeNetwork); - if (netCaps != null) { - return netCaps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) || - !netCaps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN); - } - } - } - else { - NetworkInfo activeNetworkInfo = connMgr.getActiveNetworkInfo(); - if (activeNetworkInfo != null) { - return activeNetworkInfo.getType() == ConnectivityManager.TYPE_VPN; - } - } - - return false; - } -} +package com.limelight.utils; + +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.Network; +import android.net.NetworkCapabilities; +import android.net.NetworkInfo; +import android.os.Build; + +public class NetHelper { + public static boolean isActiveNetworkVpn(Context context) { + ConnectivityManager connMgr = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + Network activeNetwork = connMgr.getActiveNetwork(); + if (activeNetwork != null) { + NetworkCapabilities netCaps = connMgr.getNetworkCapabilities(activeNetwork); + if (netCaps != null) { + return netCaps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) || + !netCaps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN); + } + } + } + else { + NetworkInfo activeNetworkInfo = connMgr.getActiveNetworkInfo(); + if (activeNetworkInfo != null) { + return activeNetworkInfo.getType() == ConnectivityManager.TYPE_VPN; + } + } + + return false; + } +} diff --git a/app/src/main/java/com/limelight/utils/ServerHelper.java b/app/src/main/java/com/limelight/utils/ServerHelper.java old mode 100644 new mode 100755 index ef5a790297..74f3e40369 --- a/app/src/main/java/com/limelight/utils/ServerHelper.java +++ b/app/src/main/java/com/limelight/utils/ServerHelper.java @@ -1,169 +1,175 @@ -package com.limelight.utils; - -import android.app.Activity; -import android.content.Intent; -import android.widget.Toast; - -import com.limelight.AppView; -import com.limelight.Game; -import com.limelight.R; -import com.limelight.ShortcutTrampoline; -import com.limelight.binding.PlatformBinding; -import com.limelight.computers.ComputerManagerService; -import com.limelight.nvstream.http.ComputerDetails; -import com.limelight.nvstream.http.HostHttpResponseException; -import com.limelight.nvstream.http.NvApp; -import com.limelight.nvstream.http.NvHTTP; -import com.limelight.nvstream.jni.MoonBridge; - -import org.xmlpull.v1.XmlPullParserException; - -import java.io.FileNotFoundException; -import java.io.IOException; -import java.net.UnknownHostException; -import java.security.cert.CertificateEncodingException; - -public class ServerHelper { - public static final String CONNECTION_TEST_SERVER = "android.conntest.moonlight-stream.org"; - - public static ComputerDetails.AddressTuple getCurrentAddressFromComputer(ComputerDetails computer) throws IOException { - if (computer.activeAddress == null) { - throw new IOException("No active address for "+computer.name); - } - return computer.activeAddress; - } - - public static Intent createPcShortcutIntent(Activity parent, ComputerDetails computer) { - Intent i = new Intent(parent, ShortcutTrampoline.class); - i.putExtra(AppView.NAME_EXTRA, computer.name); - i.putExtra(AppView.UUID_EXTRA, computer.uuid); - i.setAction(Intent.ACTION_DEFAULT); - return i; - } - - public static Intent createAppShortcutIntent(Activity parent, ComputerDetails computer, NvApp app) { - Intent i = new Intent(parent, ShortcutTrampoline.class); - i.putExtra(AppView.NAME_EXTRA, computer.name); - i.putExtra(AppView.UUID_EXTRA, computer.uuid); - i.putExtra(Game.EXTRA_APP_NAME, app.getAppName()); - i.putExtra(Game.EXTRA_APP_ID, ""+app.getAppId()); - i.putExtra(Game.EXTRA_APP_HDR, app.isHdrSupported()); - i.setAction(Intent.ACTION_DEFAULT); - return i; - } - - public static Intent createStartIntent(Activity parent, NvApp app, ComputerDetails computer, - ComputerManagerService.ComputerManagerBinder managerBinder) { - Intent intent = new Intent(parent, Game.class); - intent.putExtra(Game.EXTRA_HOST, computer.activeAddress.address); - intent.putExtra(Game.EXTRA_PORT, computer.activeAddress.port); - intent.putExtra(Game.EXTRA_HTTPS_PORT, computer.httpsPort); - intent.putExtra(Game.EXTRA_APP_NAME, app.getAppName()); - intent.putExtra(Game.EXTRA_APP_ID, app.getAppId()); - intent.putExtra(Game.EXTRA_APP_HDR, app.isHdrSupported()); - intent.putExtra(Game.EXTRA_UNIQUEID, managerBinder.getUniqueId()); - intent.putExtra(Game.EXTRA_PC_UUID, computer.uuid); - intent.putExtra(Game.EXTRA_PC_NAME, computer.name); - try { - if (computer.serverCert != null) { - intent.putExtra(Game.EXTRA_SERVER_CERT, computer.serverCert.getEncoded()); - } - } catch (CertificateEncodingException e) { - e.printStackTrace(); - } - return intent; - } - - public static void doStart(Activity parent, NvApp app, ComputerDetails computer, - ComputerManagerService.ComputerManagerBinder managerBinder) { - if (computer.state == ComputerDetails.State.OFFLINE || computer.activeAddress == null) { - Toast.makeText(parent, parent.getResources().getString(R.string.pair_pc_offline), Toast.LENGTH_SHORT).show(); - return; - } - parent.startActivity(createStartIntent(parent, app, computer, managerBinder)); - } - - public static void doNetworkTest(final Activity parent) { - new Thread(new Runnable() { - @Override - public void run() { - SpinnerDialog spinnerDialog = SpinnerDialog.displayDialog(parent, - parent.getResources().getString(R.string.nettest_title_waiting), - parent.getResources().getString(R.string.nettest_text_waiting), - false); - - int ret = MoonBridge.testClientConnectivity(CONNECTION_TEST_SERVER, 443, MoonBridge.ML_PORT_FLAG_ALL); - spinnerDialog.dismiss(); - - String dialogSummary; - if (ret == MoonBridge.ML_TEST_RESULT_INCONCLUSIVE) { - dialogSummary = parent.getResources().getString(R.string.nettest_text_inconclusive); - } - else if (ret == 0) { - dialogSummary = parent.getResources().getString(R.string.nettest_text_success); - } - else { - dialogSummary = parent.getResources().getString(R.string.nettest_text_failure); - dialogSummary += MoonBridge.stringifyPortFlags(ret, "\n"); - } - - Dialog.displayDialog(parent, - parent.getResources().getString(R.string.nettest_title_done), - dialogSummary, - false); - } - }).start(); - } - - public static void doQuit(final Activity parent, - final ComputerDetails computer, - final NvApp app, - final ComputerManagerService.ComputerManagerBinder managerBinder, - final Runnable onComplete) { - Toast.makeText(parent, parent.getResources().getString(R.string.applist_quit_app) + " " + app.getAppName() + "...", Toast.LENGTH_SHORT).show(); - new Thread(new Runnable() { - @Override - public void run() { - NvHTTP httpConn; - String message; - try { - httpConn = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(computer), computer.httpsPort, - managerBinder.getUniqueId(), computer.serverCert, PlatformBinding.getCryptoProvider(parent)); - if (httpConn.quitApp()) { - message = parent.getResources().getString(R.string.applist_quit_success) + " " + app.getAppName(); - } else { - message = parent.getResources().getString(R.string.applist_quit_fail) + " " + app.getAppName(); - } - } catch (HostHttpResponseException e) { - if (e.getErrorCode() == 599) { - message = "This session wasn't started by this device," + - " so it cannot be quit. End streaming on the original " + - "device or the PC itself. (Error code: "+e.getErrorCode()+")"; - } - else { - message = e.getMessage(); - } - } catch (UnknownHostException e) { - message = parent.getResources().getString(R.string.error_unknown_host); - } catch (FileNotFoundException e) { - message = parent.getResources().getString(R.string.error_404); - } catch (IOException | XmlPullParserException e) { - message = e.getMessage(); - e.printStackTrace(); - } finally { - if (onComplete != null) { - onComplete.run(); - } - } - - final String toastMessage = message; - parent.runOnUiThread(new Runnable() { - @Override - public void run() { - Toast.makeText(parent, toastMessage, Toast.LENGTH_LONG).show(); - } - }); - } - }).start(); - } -} +package com.limelight.utils; + +import android.app.Activity; +import android.content.Intent; +import android.widget.Toast; + +import com.limelight.AppView; +import com.limelight.Game; +import com.limelight.GameSbs; +import com.limelight.R; +import com.limelight.ShortcutTrampoline; +import com.limelight.binding.PlatformBinding; +import com.limelight.computers.ComputerManagerService; +import com.limelight.nvstream.http.ComputerDetails; +import com.limelight.nvstream.http.HostHttpResponseException; +import com.limelight.nvstream.http.NvApp; +import com.limelight.nvstream.http.NvHTTP; +import com.limelight.nvstream.jni.MoonBridge; +import com.limelight.preferences.PreferenceConfiguration; + +import org.xmlpull.v1.XmlPullParserException; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.net.UnknownHostException; +import java.security.cert.CertificateEncodingException; + +public class ServerHelper { + public static final String CONNECTION_TEST_SERVER = "android.conntest.moonlight-stream.org"; + + public static ComputerDetails.AddressTuple getCurrentAddressFromComputer(ComputerDetails computer) throws IOException { + if (computer.activeAddress == null) { + throw new IOException("No active address for "+computer.name); + } + return computer.activeAddress; + } + + public static Intent createPcShortcutIntent(Activity parent, ComputerDetails computer) { + Intent i = new Intent(parent, ShortcutTrampoline.class); + i.putExtra(AppView.NAME_EXTRA, computer.name); + i.putExtra(AppView.UUID_EXTRA, computer.uuid); + i.setAction(Intent.ACTION_DEFAULT); + return i; + } + + public static Intent createAppShortcutIntent(Activity parent, ComputerDetails computer, NvApp app) { + Intent i = new Intent(parent, ShortcutTrampoline.class); + i.putExtra(AppView.NAME_EXTRA, computer.name); + i.putExtra(AppView.UUID_EXTRA, computer.uuid); + i.putExtra(Game.EXTRA_APP_NAME, app.getAppName()); + i.putExtra(Game.EXTRA_APP_ID, ""+app.getAppId()); + i.putExtra(Game.EXTRA_APP_HDR, app.isHdrSupported()); + i.setAction(Intent.ACTION_DEFAULT); + return i; + } + + public static Intent createStartIntent(Activity parent, NvApp app, ComputerDetails computer, + ComputerManagerService.ComputerManagerBinder managerBinder) { + + Intent intent = new Intent(parent, Game.class); + if(PreferenceConfiguration.readPreferences(parent).enableSbs){ + intent.setClass(parent,GameSbs.class); + } + intent.putExtra(Game.EXTRA_HOST, computer.activeAddress.address); + intent.putExtra(Game.EXTRA_PORT, computer.activeAddress.port); + intent.putExtra(Game.EXTRA_HTTPS_PORT, computer.httpsPort); + intent.putExtra(Game.EXTRA_APP_NAME, app.getAppName()); + intent.putExtra(Game.EXTRA_APP_ID, app.getAppId()); + intent.putExtra(Game.EXTRA_APP_HDR, app.isHdrSupported()); + intent.putExtra(Game.EXTRA_UNIQUEID, managerBinder.getUniqueId()); + intent.putExtra(Game.EXTRA_PC_UUID, computer.uuid); + intent.putExtra(Game.EXTRA_PC_NAME, computer.name); + try { + if (computer.serverCert != null) { + intent.putExtra(Game.EXTRA_SERVER_CERT, computer.serverCert.getEncoded()); + } + } catch (CertificateEncodingException e) { + e.printStackTrace(); + } + return intent; + } + + public static void doStart(Activity parent, NvApp app, ComputerDetails computer, + ComputerManagerService.ComputerManagerBinder managerBinder) { + if (computer.state == ComputerDetails.State.OFFLINE || computer.activeAddress == null) { + Toast.makeText(parent, parent.getResources().getString(R.string.pair_pc_offline), Toast.LENGTH_SHORT).show(); + return; + } + parent.startActivity(createStartIntent(parent, app, computer, managerBinder)); + } + + public static void doNetworkTest(final Activity parent) { + new Thread(new Runnable() { + @Override + public void run() { + SpinnerDialog spinnerDialog = SpinnerDialog.displayDialog(parent, + parent.getResources().getString(R.string.nettest_title_waiting), + parent.getResources().getString(R.string.nettest_text_waiting), + false); + + int ret = MoonBridge.testClientConnectivity(CONNECTION_TEST_SERVER, 443, MoonBridge.ML_PORT_FLAG_ALL); + spinnerDialog.dismiss(); + + String dialogSummary; + if (ret == MoonBridge.ML_TEST_RESULT_INCONCLUSIVE) { + dialogSummary = parent.getResources().getString(R.string.nettest_text_inconclusive); + } + else if (ret == 0) { + dialogSummary = parent.getResources().getString(R.string.nettest_text_success); + } + else { + dialogSummary = parent.getResources().getString(R.string.nettest_text_failure); + dialogSummary += MoonBridge.stringifyPortFlags(ret, "\n"); + } + + Dialog.displayDialog(parent, + parent.getResources().getString(R.string.nettest_title_done), + dialogSummary, + false); + } + }).start(); + } + + public static void doQuit(final Activity parent, + final ComputerDetails computer, + final NvApp app, + final ComputerManagerService.ComputerManagerBinder managerBinder, + final Runnable onComplete) { + Toast.makeText(parent, parent.getResources().getString(R.string.applist_quit_app) + " " + app.getAppName() + "...", Toast.LENGTH_SHORT).show(); + new Thread(new Runnable() { + @Override + public void run() { + NvHTTP httpConn; + String message; + try { + httpConn = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(computer), computer.httpsPort, + managerBinder.getUniqueId(), computer.serverCert, PlatformBinding.getCryptoProvider(parent)); + if (httpConn.quitApp()) { + message = parent.getResources().getString(R.string.applist_quit_success) + " " + app.getAppName(); + } else { + message = parent.getResources().getString(R.string.applist_quit_fail) + " " + app.getAppName(); + } + } catch (HostHttpResponseException e) { + if (e.getErrorCode() == 599) { + message = "This session wasn't started by this device," + + " so it cannot be quit. End streaming on the original " + + "device or the PC itself. (Error code: "+e.getErrorCode()+")"; + } + else { + message = e.getMessage(); + } + } catch (UnknownHostException e) { + message = parent.getResources().getString(R.string.error_unknown_host); + } catch (FileNotFoundException e) { + message = parent.getResources().getString(R.string.error_404); + } catch (IOException | XmlPullParserException e) { + message = e.getMessage(); + e.printStackTrace(); + } finally { + if (onComplete != null) { + onComplete.run(); + } + } + + final String toastMessage = message; + parent.runOnUiThread(new Runnable() { + @Override + public void run() { + Toast.makeText(parent, toastMessage, Toast.LENGTH_LONG).show(); + } + }); + } + }).start(); + } +} diff --git a/app/src/main/java/com/limelight/utils/ShortcutHelper.java b/app/src/main/java/com/limelight/utils/ShortcutHelper.java old mode 100644 new mode 100755 index 0799584799..88c21a5749 --- a/app/src/main/java/com/limelight/utils/ShortcutHelper.java +++ b/app/src/main/java/com/limelight/utils/ShortcutHelper.java @@ -1,212 +1,212 @@ -package com.limelight.utils; - -import android.annotation.TargetApi; -import android.app.Activity; -import android.content.Intent; -import android.content.pm.ShortcutInfo; -import android.content.pm.ShortcutManager; -import android.graphics.Bitmap; -import android.graphics.drawable.Icon; -import android.os.Build; - -import com.limelight.AppView; -import com.limelight.ShortcutTrampoline; -import com.limelight.R; -import com.limelight.nvstream.http.ComputerDetails; -import com.limelight.nvstream.http.NvApp; - -import java.util.Collections; -import java.util.LinkedList; -import java.util.List; - -public class ShortcutHelper { - - private final ShortcutManager sm; - private final Activity context; - private final TvChannelHelper tvChannelHelper; - - public ShortcutHelper(Activity context) { - this.context = context; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { - sm = context.getSystemService(ShortcutManager.class); - } - else { - sm = null; - } - this.tvChannelHelper = new TvChannelHelper(context); - } - - @TargetApi(Build.VERSION_CODES.N_MR1) - private void reapShortcutsForDynamicAdd() { - List dynamicShortcuts = sm.getDynamicShortcuts(); - while (!dynamicShortcuts.isEmpty() && dynamicShortcuts.size() >= sm.getMaxShortcutCountPerActivity()) { - ShortcutInfo maxRankShortcut = dynamicShortcuts.get(0); - for (ShortcutInfo scut : dynamicShortcuts) { - if (maxRankShortcut.getRank() < scut.getRank()) { - maxRankShortcut = scut; - } - } - sm.removeDynamicShortcuts(Collections.singletonList(maxRankShortcut.getId())); - } - } - - @TargetApi(Build.VERSION_CODES.N_MR1) - private List getAllShortcuts() { - LinkedList list = new LinkedList<>(); - list.addAll(sm.getDynamicShortcuts()); - list.addAll(sm.getPinnedShortcuts()); - return list; - } - - @TargetApi(Build.VERSION_CODES.N_MR1) - private ShortcutInfo getInfoForId(String id) { - List shortcuts = getAllShortcuts(); - - for (ShortcutInfo info : shortcuts) { - if (info.getId().equals(id)) { - return info; - } - } - - return null; - } - - @TargetApi(Build.VERSION_CODES.N_MR1) - private boolean isExistingDynamicShortcut(String id) { - for (ShortcutInfo si : sm.getDynamicShortcuts()) { - if (si.getId().equals(id)) { - return true; - } - } - - return false; - } - - public void reportComputerShortcutUsed(ComputerDetails computer) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { - if (getInfoForId(computer.uuid) != null) { - sm.reportShortcutUsed(computer.uuid); - } - } - } - - public void reportGameLaunched(ComputerDetails computer, NvApp app) { - tvChannelHelper.createTvChannel(computer); - tvChannelHelper.addGameToChannel(computer, app); - } - - public void createAppViewShortcut(ComputerDetails computer, boolean forceAdd, boolean newlyPaired) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { - ShortcutInfo sinfo = new ShortcutInfo.Builder(context, computer.uuid) - .setIntent(ServerHelper.createPcShortcutIntent(context, computer)) - .setShortLabel(computer.name) - .setLongLabel(computer.name) - .setIcon(Icon.createWithResource(context, R.mipmap.ic_pc_scut)) - .build(); - - ShortcutInfo existingSinfo = getInfoForId(computer.uuid); - if (existingSinfo != null) { - // Update in place - sm.updateShortcuts(Collections.singletonList(sinfo)); - sm.enableShortcuts(Collections.singletonList(computer.uuid)); - } - - // Reap shortcuts to make space for this if it's new - // NOTE: This CAN'T be an else on the above if, because it's - // possible that we have an existing shortcut but it's not a dynamic one. - if (!isExistingDynamicShortcut(computer.uuid)) { - // To avoid a random carousel of shortcuts popping in and out based on polling status, - // we only add shortcuts if it's not at the limit or the user made a conscious action - // to interact with this PC. - - if (forceAdd) { - // This should free an entry for us to add one below - reapShortcutsForDynamicAdd(); - } - - // We still need to check the maximum shortcut count even after reaping, - // because there's a possibility that it could be zero. - if (sm.getDynamicShortcuts().size() < sm.getMaxShortcutCountPerActivity()) { - // Add a shortcut if there is room - sm.addDynamicShortcuts(Collections.singletonList(sinfo)); - } - } - } - - if (newlyPaired) { - // Avoid hammering the channel API for each computer poll because it will throttle us - tvChannelHelper.createTvChannel(computer); - tvChannelHelper.requestChannelOnHomeScreen(computer); - } - } - - public void createAppViewShortcutForOnlineHost(ComputerDetails details) { - createAppViewShortcut(details, false, false); - } - - private String getShortcutIdForGame(ComputerDetails computer, NvApp app) { - return computer.uuid + app.getAppId(); - } - - @TargetApi(Build.VERSION_CODES.O) - public boolean createPinnedGameShortcut(ComputerDetails computer, NvApp app, Bitmap iconBits) { - if (sm.isRequestPinShortcutSupported()) { - Icon appIcon; - - if (iconBits != null) { - appIcon = Icon.createWithAdaptiveBitmap(iconBits); - } else { - appIcon = Icon.createWithResource(context, R.mipmap.ic_pc_scut); - } - - ShortcutInfo sInfo = new ShortcutInfo.Builder(context, getShortcutIdForGame(computer, app)) - .setIntent(ServerHelper.createAppShortcutIntent(context, computer, app)) - .setShortLabel(app.getAppName() + " (" + computer.name + ")") - .setIcon(appIcon) - .build(); - - return sm.requestPinShortcut(sInfo, null); - } else { - return false; - } - } - - public void disableComputerShortcut(ComputerDetails computer, CharSequence reason) { - tvChannelHelper.deleteChannel(computer); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { - // Delete the computer shortcut itself - if (getInfoForId(computer.uuid) != null) { - sm.disableShortcuts(Collections.singletonList(computer.uuid), reason); - } - - // Delete all associated app shortcuts too - List shortcuts = getAllShortcuts(); - LinkedList appShortcutIds = new LinkedList<>(); - for (ShortcutInfo info : shortcuts) { - if (info.getId().startsWith(computer.uuid)) { - appShortcutIds.add(info.getId()); - } - } - sm.disableShortcuts(appShortcutIds, reason); - } - } - - public void disableAppShortcut(ComputerDetails computer, NvApp app, CharSequence reason) { - tvChannelHelper.deleteProgram(computer, app); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { - String id = getShortcutIdForGame(computer, app); - if (getInfoForId(id) != null) { - sm.disableShortcuts(Collections.singletonList(id), reason); - } - } - } - - public void enableAppShortcut(ComputerDetails computer, NvApp app) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { - String id = getShortcutIdForGame(computer, app); - if (getInfoForId(id) != null) { - sm.enableShortcuts(Collections.singletonList(id)); - } - } - } -} +package com.limelight.utils; + +import android.annotation.TargetApi; +import android.app.Activity; +import android.content.Intent; +import android.content.pm.ShortcutInfo; +import android.content.pm.ShortcutManager; +import android.graphics.Bitmap; +import android.graphics.drawable.Icon; +import android.os.Build; + +import com.limelight.AppView; +import com.limelight.ShortcutTrampoline; +import com.limelight.R; +import com.limelight.nvstream.http.ComputerDetails; +import com.limelight.nvstream.http.NvApp; + +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; + +public class ShortcutHelper { + + private final ShortcutManager sm; + private final Activity context; + private final TvChannelHelper tvChannelHelper; + + public ShortcutHelper(Activity context) { + this.context = context; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { + sm = context.getSystemService(ShortcutManager.class); + } + else { + sm = null; + } + this.tvChannelHelper = new TvChannelHelper(context); + } + + @TargetApi(Build.VERSION_CODES.N_MR1) + private void reapShortcutsForDynamicAdd() { + List dynamicShortcuts = sm.getDynamicShortcuts(); + while (!dynamicShortcuts.isEmpty() && dynamicShortcuts.size() >= sm.getMaxShortcutCountPerActivity()) { + ShortcutInfo maxRankShortcut = dynamicShortcuts.get(0); + for (ShortcutInfo scut : dynamicShortcuts) { + if (maxRankShortcut.getRank() < scut.getRank()) { + maxRankShortcut = scut; + } + } + sm.removeDynamicShortcuts(Collections.singletonList(maxRankShortcut.getId())); + } + } + + @TargetApi(Build.VERSION_CODES.N_MR1) + private List getAllShortcuts() { + LinkedList list = new LinkedList<>(); + list.addAll(sm.getDynamicShortcuts()); + list.addAll(sm.getPinnedShortcuts()); + return list; + } + + @TargetApi(Build.VERSION_CODES.N_MR1) + private ShortcutInfo getInfoForId(String id) { + List shortcuts = getAllShortcuts(); + + for (ShortcutInfo info : shortcuts) { + if (info.getId().equals(id)) { + return info; + } + } + + return null; + } + + @TargetApi(Build.VERSION_CODES.N_MR1) + private boolean isExistingDynamicShortcut(String id) { + for (ShortcutInfo si : sm.getDynamicShortcuts()) { + if (si.getId().equals(id)) { + return true; + } + } + + return false; + } + + public void reportComputerShortcutUsed(ComputerDetails computer) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { + if (getInfoForId(computer.uuid) != null) { + sm.reportShortcutUsed(computer.uuid); + } + } + } + + public void reportGameLaunched(ComputerDetails computer, NvApp app) { + tvChannelHelper.createTvChannel(computer); + tvChannelHelper.addGameToChannel(computer, app); + } + + public void createAppViewShortcut(ComputerDetails computer, boolean forceAdd, boolean newlyPaired) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { + ShortcutInfo sinfo = new ShortcutInfo.Builder(context, computer.uuid) + .setIntent(ServerHelper.createPcShortcutIntent(context, computer)) + .setShortLabel(computer.name) + .setLongLabel(computer.name) + .setIcon(Icon.createWithResource(context, R.mipmap.ic_pc_scut)) + .build(); + + ShortcutInfo existingSinfo = getInfoForId(computer.uuid); + if (existingSinfo != null) { + // Update in place + sm.updateShortcuts(Collections.singletonList(sinfo)); + sm.enableShortcuts(Collections.singletonList(computer.uuid)); + } + + // Reap shortcuts to make space for this if it's new + // NOTE: This CAN'T be an else on the above if, because it's + // possible that we have an existing shortcut but it's not a dynamic one. + if (!isExistingDynamicShortcut(computer.uuid)) { + // To avoid a random carousel of shortcuts popping in and out based on polling status, + // we only add shortcuts if it's not at the limit or the user made a conscious action + // to interact with this PC. + + if (forceAdd) { + // This should free an entry for us to add one below + reapShortcutsForDynamicAdd(); + } + + // We still need to check the maximum shortcut count even after reaping, + // because there's a possibility that it could be zero. + if (sm.getDynamicShortcuts().size() < sm.getMaxShortcutCountPerActivity()) { + // Add a shortcut if there is room + sm.addDynamicShortcuts(Collections.singletonList(sinfo)); + } + } + } + + if (newlyPaired) { + // Avoid hammering the channel API for each computer poll because it will throttle us + tvChannelHelper.createTvChannel(computer); + tvChannelHelper.requestChannelOnHomeScreen(computer); + } + } + + public void createAppViewShortcutForOnlineHost(ComputerDetails details) { + createAppViewShortcut(details, false, false); + } + + private String getShortcutIdForGame(ComputerDetails computer, NvApp app) { + return computer.uuid + app.getAppId(); + } + + @TargetApi(Build.VERSION_CODES.O) + public boolean createPinnedGameShortcut(ComputerDetails computer, NvApp app, Bitmap iconBits) { + if (sm.isRequestPinShortcutSupported()) { + Icon appIcon; + + if (iconBits != null) { + appIcon = Icon.createWithAdaptiveBitmap(iconBits); + } else { + appIcon = Icon.createWithResource(context, R.mipmap.ic_pc_scut); + } + + ShortcutInfo sInfo = new ShortcutInfo.Builder(context, getShortcutIdForGame(computer, app)) + .setIntent(ServerHelper.createAppShortcutIntent(context, computer, app)) + .setShortLabel(app.getAppName() + " (" + computer.name + ")") + .setIcon(appIcon) + .build(); + + return sm.requestPinShortcut(sInfo, null); + } else { + return false; + } + } + + public void disableComputerShortcut(ComputerDetails computer, CharSequence reason) { + tvChannelHelper.deleteChannel(computer); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { + // Delete the computer shortcut itself + if (getInfoForId(computer.uuid) != null) { + sm.disableShortcuts(Collections.singletonList(computer.uuid), reason); + } + + // Delete all associated app shortcuts too + List shortcuts = getAllShortcuts(); + LinkedList appShortcutIds = new LinkedList<>(); + for (ShortcutInfo info : shortcuts) { + if (info.getId().startsWith(computer.uuid)) { + appShortcutIds.add(info.getId()); + } + } + sm.disableShortcuts(appShortcutIds, reason); + } + } + + public void disableAppShortcut(ComputerDetails computer, NvApp app, CharSequence reason) { + tvChannelHelper.deleteProgram(computer, app); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { + String id = getShortcutIdForGame(computer, app); + if (getInfoForId(id) != null) { + sm.disableShortcuts(Collections.singletonList(id), reason); + } + } + } + + public void enableAppShortcut(ComputerDetails computer, NvApp app) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { + String id = getShortcutIdForGame(computer, app); + if (getInfoForId(id) != null) { + sm.enableShortcuts(Collections.singletonList(id)); + } + } + } +} diff --git a/app/src/main/java/com/limelight/utils/SpinnerDialog.java b/app/src/main/java/com/limelight/utils/SpinnerDialog.java old mode 100644 new mode 100755 index 01fe269956..c5a7152c41 --- a/app/src/main/java/com/limelight/utils/SpinnerDialog.java +++ b/app/src/main/java/com/limelight/utils/SpinnerDialog.java @@ -1,120 +1,120 @@ -package com.limelight.utils; - -import java.util.ArrayList; -import java.util.Iterator; - -import android.app.Activity; -import android.app.ProgressDialog; -import android.content.DialogInterface; -import android.content.DialogInterface.OnCancelListener; - -public class SpinnerDialog implements Runnable,OnCancelListener { - private final String title; - private final String message; - private final Activity activity; - private ProgressDialog progress; - private final boolean finish; - - private static final ArrayList rundownDialogs = new ArrayList<>(); - - private SpinnerDialog(Activity activity, String title, String message, boolean finish) - { - this.activity = activity; - this.title = title; - this.message = message; - this.progress = null; - this.finish = finish; - } - - public static SpinnerDialog displayDialog(Activity activity, String title, String message, boolean finish) - { - SpinnerDialog spinner = new SpinnerDialog(activity, title, message, finish); - activity.runOnUiThread(spinner); - return spinner; - } - - public static void closeDialogs(Activity activity) - { - synchronized (rundownDialogs) { - Iterator i = rundownDialogs.iterator(); - while (i.hasNext()) { - SpinnerDialog dialog = i.next(); - if (dialog.activity == activity) { - i.remove(); - if (dialog.progress.isShowing()) { - dialog.progress.dismiss(); - } - } - } - } - } - - public void dismiss() - { - // Running again with progress != null will destroy it - activity.runOnUiThread(this); - } - - public void setMessage(final String message) - { - activity.runOnUiThread(new Runnable() { - @Override - public void run() { - progress.setMessage(message); - } - }); - } - - @Override - public void run() { - - // If we're dying, don't bother doing anything - if (activity.isFinishing()) { - return; - } - - if (progress == null) - { - progress = new ProgressDialog(activity); - - progress.setTitle(title); - progress.setMessage(message); - progress.setProgressStyle(ProgressDialog.STYLE_SPINNER); - progress.setOnCancelListener(this); - - // If we want to finish the activity when this is killed, make it cancellable - if (finish) - { - progress.setCancelable(true); - progress.setCanceledOnTouchOutside(false); - } - else - { - progress.setCancelable(false); - } - - synchronized (rundownDialogs) { - rundownDialogs.add(this); - progress.show(); - } - } - else - { - synchronized (rundownDialogs) { - if (rundownDialogs.remove(this) && progress.isShowing()) { - progress.dismiss(); - } - } - } - } - - @Override - public void onCancel(DialogInterface dialog) { - synchronized (rundownDialogs) { - rundownDialogs.remove(this); - } - - // This will only be called if finish was true, so we don't need to check again - activity.finish(); - } -} +package com.limelight.utils; + +import java.util.ArrayList; +import java.util.Iterator; + +import android.app.Activity; +import android.app.ProgressDialog; +import android.content.DialogInterface; +import android.content.DialogInterface.OnCancelListener; + +public class SpinnerDialog implements Runnable,OnCancelListener { + private final String title; + private final String message; + private final Activity activity; + private ProgressDialog progress; + private final boolean finish; + + private static final ArrayList rundownDialogs = new ArrayList<>(); + + private SpinnerDialog(Activity activity, String title, String message, boolean finish) + { + this.activity = activity; + this.title = title; + this.message = message; + this.progress = null; + this.finish = finish; + } + + public static SpinnerDialog displayDialog(Activity activity, String title, String message, boolean finish) + { + SpinnerDialog spinner = new SpinnerDialog(activity, title, message, finish); + activity.runOnUiThread(spinner); + return spinner; + } + + public static void closeDialogs(Activity activity) + { + synchronized (rundownDialogs) { + Iterator i = rundownDialogs.iterator(); + while (i.hasNext()) { + SpinnerDialog dialog = i.next(); + if (dialog.activity == activity) { + i.remove(); + if (dialog.progress.isShowing()) { + dialog.progress.dismiss(); + } + } + } + } + } + + public void dismiss() + { + // Running again with progress != null will destroy it + activity.runOnUiThread(this); + } + + public void setMessage(final String message) + { + activity.runOnUiThread(new Runnable() { + @Override + public void run() { + progress.setMessage(message); + } + }); + } + + @Override + public void run() { + + // If we're dying, don't bother doing anything + if (activity.isFinishing()) { + return; + } + + if (progress == null) + { + progress = new ProgressDialog(activity); + + progress.setTitle(title); + progress.setMessage(message); + progress.setProgressStyle(ProgressDialog.STYLE_SPINNER); + progress.setOnCancelListener(this); + + // If we want to finish the activity when this is killed, make it cancellable + if (finish) + { + progress.setCancelable(true); + progress.setCanceledOnTouchOutside(false); + } + else + { + progress.setCancelable(false); + } + + synchronized (rundownDialogs) { + rundownDialogs.add(this); + progress.show(); + } + } + else + { + synchronized (rundownDialogs) { + if (rundownDialogs.remove(this) && progress.isShowing()) { + progress.dismiss(); + } + } + } + } + + @Override + public void onCancel(DialogInterface dialog) { + synchronized (rundownDialogs) { + rundownDialogs.remove(this); + } + + // This will only be called if finish was true, so we don't need to check again + activity.finish(); + } +} diff --git a/app/src/main/java/com/limelight/utils/TrafficStatsHelper.java b/app/src/main/java/com/limelight/utils/TrafficStatsHelper.java new file mode 100755 index 0000000000..38cb6c0888 --- /dev/null +++ b/app/src/main/java/com/limelight/utils/TrafficStatsHelper.java @@ -0,0 +1,37 @@ +package com.limelight.utils; + +import android.net.TrafficStats; + +public class TrafficStatsHelper { + public static long getAllRxBytes() { + return TrafficStats.getTotalRxBytes(); + } + + public static long getAllTxBytes() { + return TrafficStats.getTotalTxBytes(); + } + + public static long getAllRxBytesMobile() { + return TrafficStats.getMobileRxBytes(); + } + + public static long getAllTxBytesMobile() { + return TrafficStats.getMobileTxBytes(); + } + + public static long getAllRxBytesWifi() { + return TrafficStats.getTotalRxBytes() - TrafficStats.getMobileRxBytes(); + } + + public static long getAllTxBytesWifi() { + return TrafficStats.getTotalTxBytes() - TrafficStats.getMobileTxBytes(); + } + + public static long getPackageRxBytes(int uid) { + return TrafficStats.getUidRxBytes(uid); + } + + public static long getPackageTxBytes(int uid) { + return TrafficStats.getUidTxBytes(uid); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/limelight/utils/TvChannelHelper.java b/app/src/main/java/com/limelight/utils/TvChannelHelper.java old mode 100644 new mode 100755 index 0a144b20d7..5e922b17c9 --- a/app/src/main/java/com/limelight/utils/TvChannelHelper.java +++ b/app/src/main/java/com/limelight/utils/TvChannelHelper.java @@ -1,366 +1,366 @@ -package com.limelight.utils; - -import android.annotation.TargetApi; -import android.app.Activity; -import android.content.ActivityNotFoundException; -import android.content.ContentUris; -import android.content.ContentValues; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.database.Cursor; -import android.database.sqlite.SQLiteException; -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.drawable.Drawable; -import android.media.tv.TvContract; -import android.net.Uri; -import android.os.Build; - -import com.limelight.LimeLog; -import com.limelight.PosterContentProvider; -import com.limelight.R; -import com.limelight.nvstream.http.ComputerDetails; -import com.limelight.nvstream.http.NvApp; - -import java.io.IOException; -import java.io.OutputStream; - -public class TvChannelHelper { - - private static final int ASPECT_RATIO_MOVIE_POSTER = 5; - private static final int TYPE_GAME = 12; - private static final int INTERNAL_PROVIDER_ID_INDEX = 1; - private static final int PROGRAM_BROWSABLE_INDEX = 2; - private static final int ID_INDEX = 0; - private Activity context; - - public TvChannelHelper(Activity context) { - this.context = context; - } - - void requestChannelOnHomeScreen(ComputerDetails computer) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - if (!isAndroidTV()) { - return; - } - - Long channelId = getChannelId(computer.uuid); - if (channelId == null) { - return; - } - - Intent intent = new Intent(TvContract.ACTION_REQUEST_CHANNEL_BROWSABLE); - intent.putExtra(TvContract.EXTRA_CHANNEL_ID, getChannelId(computer.uuid)); - try { - context.startActivityForResult(intent, 0); - } catch (Exception ignored) { - // ActivityNotFoundException is the only officially documented - // exception that can result from this call. However some buggy - // devices throw others. - // See https://github.com/moonlight-stream/moonlight-android/issues/1302 - } - } - } - - void createTvChannel(ComputerDetails computer) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - if (!isAndroidTV()) { - return; - } - - ChannelBuilder builder = new ChannelBuilder() - .setType(TvContract.Channels.TYPE_PREVIEW) - .setDisplayName(computer.name) - .setInternalProviderId(computer.uuid) - .setAppLinkIntent(ServerHelper.createPcShortcutIntent(context, computer)); - - Long channelId = getChannelId(computer.uuid); - if (channelId != null) { - context.getContentResolver().update(TvContract.buildChannelUri(channelId), - builder.toContentValues(), null, null); - return; - } - - Uri channelUri; - - try { - channelUri = context.getContentResolver().insert( - TvContract.Channels.CONTENT_URI, builder.toContentValues()); - } catch (IllegalArgumentException e) { - // This can happen on HarmonyOS devices which report to - // support Leanback APIs, yet don't implement this URI - e.printStackTrace(); - return; - } - - if (channelUri != null) { - long id = ContentUris.parseId(channelUri); - updateChannelIcon(id); - } - } - } - - @TargetApi(Build.VERSION_CODES.O) - private void updateChannelIcon(long channelId) { - Bitmap logo = drawableToBitmap(context.getResources().getDrawable(R.drawable.ic_channel)); - try { - Uri localUri = TvContract.buildChannelLogoUri(channelId); - try (OutputStream outputStream = context.getContentResolver().openOutputStream(localUri)) { - logo.compress(Bitmap.CompressFormat.PNG, 100, outputStream); - outputStream.flush(); - } catch (SQLiteException | IOException e) { - LimeLog.warning("Failed to store the logo to the system content provider."); - e.printStackTrace(); - } - } finally { - logo.recycle(); - } - } - - private Bitmap drawableToBitmap(Drawable drawable) { - int width = context.getResources().getDimensionPixelSize(R.dimen.tv_channel_logo_width); - int height = context.getResources().getDimensionPixelSize(R.dimen.tv_channel_logo_width); - - Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); - Canvas canvas = new Canvas(bitmap); - drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); - drawable.draw(canvas); - return bitmap; - } - - void addGameToChannel(ComputerDetails computer, NvApp app) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - if (!isAndroidTV()) { - return; - } - - - Long channelId = getChannelId(computer.uuid); - if (channelId == null) { - return; - } - - PreviewProgramBuilder builder = new PreviewProgramBuilder() - .setChannelId(channelId) - .setType(TYPE_GAME) - .setTitle(app.getAppName()) - .setPosterArtAspectRatio(ASPECT_RATIO_MOVIE_POSTER) - .setPosterArtUri(PosterContentProvider.createBoxArtUri(computer.uuid, ""+app.getAppId())) - .setIntent(ServerHelper.createAppShortcutIntent(context, computer, app)) - .setInternalProviderId(""+app.getAppId()) - // Weight should increase each time we run the game - .setWeight((int)((System.currentTimeMillis() - 1500000000000L) / 1000)); - - Long programId = getProgramId(channelId, ""+app.getAppId()); - if (programId != null) { - context.getContentResolver().update(TvContract.buildPreviewProgramUri(programId), - builder.toContentValues(), null, null); - return; - } - - try { - context.getContentResolver().insert(TvContract.PreviewPrograms.CONTENT_URI, - builder.toContentValues()); - } catch (IllegalArgumentException e) { - // This can happen on HarmonyOS devices which report to - // support Leanback APIs, yet don't implement this URI - e.printStackTrace(); - return; - } - - TvContract.requestChannelBrowsable(context, channelId); - } - } - - void deleteChannel(ComputerDetails computer) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - if (!isAndroidTV()) { - return; - } - - Long channelId = getChannelId(computer.uuid); - if (channelId == null) { - return; - } - - context.getContentResolver().delete(TvContract.buildChannelUri(channelId), null, null); - } - } - - void deleteProgram(ComputerDetails computer, NvApp app) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - if (!isAndroidTV()) { - return; - } - - Long channelId = getChannelId(computer.uuid); - if (channelId == null) { - return; - } - - - Long programId = getProgramId(channelId, ""+app.getAppId()); - if (programId == null) { - return; - } - - context.getContentResolver().delete(TvContract.buildPreviewProgramUri(programId), null, null); - } - } - - @TargetApi(Build.VERSION_CODES.O) - private Long getChannelId(String computerUuid) { - try (Cursor cursor = context.getContentResolver().query( - TvContract.Channels.CONTENT_URI, - new String[] {TvContract.Channels._ID, TvContract.Channels.COLUMN_INTERNAL_PROVIDER_ID}, - null, - null, - null)) { - if (cursor == null || cursor.getCount() == 0) { - return null; - } - while (cursor.moveToNext()) { - String internalProviderId = cursor.getString(INTERNAL_PROVIDER_ID_INDEX); - if (computerUuid.equals(internalProviderId)) { - return cursor.getLong(ID_INDEX); - } - } - - return null; - } - } - - @TargetApi(Build.VERSION_CODES.O) - private Long getProgramId(long channelId, String appId) { - try (Cursor cursor = context.getContentResolver().query( - TvContract.buildPreviewProgramsUriForChannel(channelId), - new String[] {TvContract.PreviewPrograms._ID, TvContract.PreviewPrograms.COLUMN_INTERNAL_PROVIDER_ID, TvContract.PreviewPrograms.COLUMN_BROWSABLE}, - null, - null, - null)) { - if (cursor == null || cursor.getCount() == 0) { - return null; - } - while (cursor.moveToNext()) { - String internalProviderId = cursor.getString(INTERNAL_PROVIDER_ID_INDEX); - if (appId.equals(internalProviderId)) { - long id = cursor.getLong(ID_INDEX); - int browsable = cursor.getInt(PROGRAM_BROWSABLE_INDEX); - if (browsable != 0) { - return id; - } else { - int countDeleted = context.getContentResolver().delete(TvContract.buildPreviewProgramUri(id), null, null); - if (countDeleted > 0) { - LimeLog.info("Preview program has been deleted"); - } else { - LimeLog.warning("Preview program has not been deleted"); - } - } - } - } - - return null; - } - } - - private static String toValueString(T value) { - return value == null ? null : value.toString(); - } - - private static String toUriString(Intent intent) { - return intent == null ? null : intent.toUri(Intent.URI_INTENT_SCHEME); - } - - @TargetApi(Build.VERSION_CODES.O) - private boolean isAndroidTV() { - return context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_LEANBACK); - } - - @TargetApi(Build.VERSION_CODES.O) - private static class PreviewProgramBuilder { - - private ContentValues mValues = new ContentValues(); - - - public PreviewProgramBuilder setChannelId(Long channelId) { - mValues.put(TvContract.PreviewPrograms.COLUMN_CHANNEL_ID, channelId); - return this; - } - - public PreviewProgramBuilder setType(int type) { - mValues.put(TvContract.PreviewPrograms.COLUMN_TYPE, type); - return this; - } - - public PreviewProgramBuilder setTitle(String title) { - mValues.put(TvContract.PreviewPrograms.COLUMN_TITLE, title); - return this; - } - - public PreviewProgramBuilder setPosterArtAspectRatio(int aspectRatio) { - mValues.put(TvContract.PreviewPrograms.COLUMN_POSTER_ART_ASPECT_RATIO, aspectRatio); - return this; - } - - public PreviewProgramBuilder setIntent(Intent intent) { - mValues.put(TvContract.PreviewPrograms.COLUMN_INTENT_URI, toUriString(intent)); - return this; - } - - public PreviewProgramBuilder setIntentUri(Uri uri) { - mValues.put(TvContract.PreviewPrograms.COLUMN_INTENT_URI, toValueString(uri)); - return this; - } - - public PreviewProgramBuilder setInternalProviderId(String id) { - mValues.put(TvContract.PreviewPrograms.COLUMN_INTERNAL_PROVIDER_ID, id); - return this; - } - - public PreviewProgramBuilder setPosterArtUri(Uri uri) { - mValues.put(TvContract.PreviewPrograms.COLUMN_POSTER_ART_URI, toValueString(uri)); - return this; - } - - public PreviewProgramBuilder setWeight(int weight) { - mValues.put(TvContract.PreviewPrograms.COLUMN_WEIGHT, weight); - return this; - } - - public ContentValues toContentValues() { - return new ContentValues(mValues); - } - - } - - @TargetApi(Build.VERSION_CODES.O) - private static class ChannelBuilder { - - private ContentValues mValues = new ContentValues(); - - public ChannelBuilder setType(String type) { - mValues.put(TvContract.Channels.COLUMN_TYPE, type); - return this; - } - - public ChannelBuilder setDisplayName(String displayName) { - mValues.put(TvContract.Channels.COLUMN_DISPLAY_NAME, displayName); - return this; - } - - public ChannelBuilder setInternalProviderId(String internalProviderId) { - mValues.put(TvContract.Channels.COLUMN_INTERNAL_PROVIDER_ID, internalProviderId); - return this; - } - - public ChannelBuilder setAppLinkIntent(Intent intent) { - mValues.put(TvContract.Channels.COLUMN_APP_LINK_INTENT_URI, toUriString(intent)); - return this; - } - - public ContentValues toContentValues() { - return new ContentValues(mValues); - } - - } -} +package com.limelight.utils; + +import android.annotation.TargetApi; +import android.app.Activity; +import android.content.ActivityNotFoundException; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.database.Cursor; +import android.database.sqlite.SQLiteException; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.drawable.Drawable; +import android.media.tv.TvContract; +import android.net.Uri; +import android.os.Build; + +import com.limelight.LimeLog; +import com.limelight.PosterContentProvider; +import com.limelight.R; +import com.limelight.nvstream.http.ComputerDetails; +import com.limelight.nvstream.http.NvApp; + +import java.io.IOException; +import java.io.OutputStream; + +public class TvChannelHelper { + + private static final int ASPECT_RATIO_MOVIE_POSTER = 5; + private static final int TYPE_GAME = 12; + private static final int INTERNAL_PROVIDER_ID_INDEX = 1; + private static final int PROGRAM_BROWSABLE_INDEX = 2; + private static final int ID_INDEX = 0; + private Activity context; + + public TvChannelHelper(Activity context) { + this.context = context; + } + + void requestChannelOnHomeScreen(ComputerDetails computer) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (!isAndroidTV()) { + return; + } + + Long channelId = getChannelId(computer.uuid); + if (channelId == null) { + return; + } + + Intent intent = new Intent(TvContract.ACTION_REQUEST_CHANNEL_BROWSABLE); + intent.putExtra(TvContract.EXTRA_CHANNEL_ID, getChannelId(computer.uuid)); + try { + context.startActivityForResult(intent, 0); + } catch (Exception ignored) { + // ActivityNotFoundException is the only officially documented + // exception that can result from this call. However some buggy + // devices throw others. + // See https://github.com/moonlight-stream/moonlight-android/issues/1302 + } + } + } + + void createTvChannel(ComputerDetails computer) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (!isAndroidTV()) { + return; + } + + ChannelBuilder builder = new ChannelBuilder() + .setType(TvContract.Channels.TYPE_PREVIEW) + .setDisplayName(computer.name) + .setInternalProviderId(computer.uuid) + .setAppLinkIntent(ServerHelper.createPcShortcutIntent(context, computer)); + + Long channelId = getChannelId(computer.uuid); + if (channelId != null) { + context.getContentResolver().update(TvContract.buildChannelUri(channelId), + builder.toContentValues(), null, null); + return; + } + + Uri channelUri; + + try { + channelUri = context.getContentResolver().insert( + TvContract.Channels.CONTENT_URI, builder.toContentValues()); + } catch (IllegalArgumentException e) { + // This can happen on HarmonyOS devices which report to + // support Leanback APIs, yet don't implement this URI + e.printStackTrace(); + return; + } + + if (channelUri != null) { + long id = ContentUris.parseId(channelUri); + updateChannelIcon(id); + } + } + } + + @TargetApi(Build.VERSION_CODES.O) + private void updateChannelIcon(long channelId) { + Bitmap logo = drawableToBitmap(context.getResources().getDrawable(R.drawable.ic_channel)); + try { + Uri localUri = TvContract.buildChannelLogoUri(channelId); + try (OutputStream outputStream = context.getContentResolver().openOutputStream(localUri)) { + logo.compress(Bitmap.CompressFormat.PNG, 100, outputStream); + outputStream.flush(); + } catch (SQLiteException | IOException e) { + LimeLog.warning("Failed to store the logo to the system content provider."); + e.printStackTrace(); + } + } finally { + logo.recycle(); + } + } + + private Bitmap drawableToBitmap(Drawable drawable) { + int width = context.getResources().getDimensionPixelSize(R.dimen.tv_channel_logo_width); + int height = context.getResources().getDimensionPixelSize(R.dimen.tv_channel_logo_width); + + Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); + drawable.draw(canvas); + return bitmap; + } + + void addGameToChannel(ComputerDetails computer, NvApp app) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (!isAndroidTV()) { + return; + } + + + Long channelId = getChannelId(computer.uuid); + if (channelId == null) { + return; + } + + PreviewProgramBuilder builder = new PreviewProgramBuilder() + .setChannelId(channelId) + .setType(TYPE_GAME) + .setTitle(app.getAppName()) + .setPosterArtAspectRatio(ASPECT_RATIO_MOVIE_POSTER) + .setPosterArtUri(PosterContentProvider.createBoxArtUri(computer.uuid, ""+app.getAppId())) + .setIntent(ServerHelper.createAppShortcutIntent(context, computer, app)) + .setInternalProviderId(""+app.getAppId()) + // Weight should increase each time we run the game + .setWeight((int)((System.currentTimeMillis() - 1500000000000L) / 1000)); + + Long programId = getProgramId(channelId, ""+app.getAppId()); + if (programId != null) { + context.getContentResolver().update(TvContract.buildPreviewProgramUri(programId), + builder.toContentValues(), null, null); + return; + } + + try { + context.getContentResolver().insert(TvContract.PreviewPrograms.CONTENT_URI, + builder.toContentValues()); + } catch (IllegalArgumentException e) { + // This can happen on HarmonyOS devices which report to + // support Leanback APIs, yet don't implement this URI + e.printStackTrace(); + return; + } + + TvContract.requestChannelBrowsable(context, channelId); + } + } + + void deleteChannel(ComputerDetails computer) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (!isAndroidTV()) { + return; + } + + Long channelId = getChannelId(computer.uuid); + if (channelId == null) { + return; + } + + context.getContentResolver().delete(TvContract.buildChannelUri(channelId), null, null); + } + } + + void deleteProgram(ComputerDetails computer, NvApp app) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (!isAndroidTV()) { + return; + } + + Long channelId = getChannelId(computer.uuid); + if (channelId == null) { + return; + } + + + Long programId = getProgramId(channelId, ""+app.getAppId()); + if (programId == null) { + return; + } + + context.getContentResolver().delete(TvContract.buildPreviewProgramUri(programId), null, null); + } + } + + @TargetApi(Build.VERSION_CODES.O) + private Long getChannelId(String computerUuid) { + try (Cursor cursor = context.getContentResolver().query( + TvContract.Channels.CONTENT_URI, + new String[] {TvContract.Channels._ID, TvContract.Channels.COLUMN_INTERNAL_PROVIDER_ID}, + null, + null, + null)) { + if (cursor == null || cursor.getCount() == 0) { + return null; + } + while (cursor.moveToNext()) { + String internalProviderId = cursor.getString(INTERNAL_PROVIDER_ID_INDEX); + if (computerUuid.equals(internalProviderId)) { + return cursor.getLong(ID_INDEX); + } + } + + return null; + } + } + + @TargetApi(Build.VERSION_CODES.O) + private Long getProgramId(long channelId, String appId) { + try (Cursor cursor = context.getContentResolver().query( + TvContract.buildPreviewProgramsUriForChannel(channelId), + new String[] {TvContract.PreviewPrograms._ID, TvContract.PreviewPrograms.COLUMN_INTERNAL_PROVIDER_ID, TvContract.PreviewPrograms.COLUMN_BROWSABLE}, + null, + null, + null)) { + if (cursor == null || cursor.getCount() == 0) { + return null; + } + while (cursor.moveToNext()) { + String internalProviderId = cursor.getString(INTERNAL_PROVIDER_ID_INDEX); + if (appId.equals(internalProviderId)) { + long id = cursor.getLong(ID_INDEX); + int browsable = cursor.getInt(PROGRAM_BROWSABLE_INDEX); + if (browsable != 0) { + return id; + } else { + int countDeleted = context.getContentResolver().delete(TvContract.buildPreviewProgramUri(id), null, null); + if (countDeleted > 0) { + LimeLog.info("Preview program has been deleted"); + } else { + LimeLog.warning("Preview program has not been deleted"); + } + } + } + } + + return null; + } + } + + private static String toValueString(T value) { + return value == null ? null : value.toString(); + } + + private static String toUriString(Intent intent) { + return intent == null ? null : intent.toUri(Intent.URI_INTENT_SCHEME); + } + + @TargetApi(Build.VERSION_CODES.O) + private boolean isAndroidTV() { + return context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_LEANBACK); + } + + @TargetApi(Build.VERSION_CODES.O) + private static class PreviewProgramBuilder { + + private ContentValues mValues = new ContentValues(); + + + public PreviewProgramBuilder setChannelId(Long channelId) { + mValues.put(TvContract.PreviewPrograms.COLUMN_CHANNEL_ID, channelId); + return this; + } + + public PreviewProgramBuilder setType(int type) { + mValues.put(TvContract.PreviewPrograms.COLUMN_TYPE, type); + return this; + } + + public PreviewProgramBuilder setTitle(String title) { + mValues.put(TvContract.PreviewPrograms.COLUMN_TITLE, title); + return this; + } + + public PreviewProgramBuilder setPosterArtAspectRatio(int aspectRatio) { + mValues.put(TvContract.PreviewPrograms.COLUMN_POSTER_ART_ASPECT_RATIO, aspectRatio); + return this; + } + + public PreviewProgramBuilder setIntent(Intent intent) { + mValues.put(TvContract.PreviewPrograms.COLUMN_INTENT_URI, toUriString(intent)); + return this; + } + + public PreviewProgramBuilder setIntentUri(Uri uri) { + mValues.put(TvContract.PreviewPrograms.COLUMN_INTENT_URI, toValueString(uri)); + return this; + } + + public PreviewProgramBuilder setInternalProviderId(String id) { + mValues.put(TvContract.PreviewPrograms.COLUMN_INTERNAL_PROVIDER_ID, id); + return this; + } + + public PreviewProgramBuilder setPosterArtUri(Uri uri) { + mValues.put(TvContract.PreviewPrograms.COLUMN_POSTER_ART_URI, toValueString(uri)); + return this; + } + + public PreviewProgramBuilder setWeight(int weight) { + mValues.put(TvContract.PreviewPrograms.COLUMN_WEIGHT, weight); + return this; + } + + public ContentValues toContentValues() { + return new ContentValues(mValues); + } + + } + + @TargetApi(Build.VERSION_CODES.O) + private static class ChannelBuilder { + + private ContentValues mValues = new ContentValues(); + + public ChannelBuilder setType(String type) { + mValues.put(TvContract.Channels.COLUMN_TYPE, type); + return this; + } + + public ChannelBuilder setDisplayName(String displayName) { + mValues.put(TvContract.Channels.COLUMN_DISPLAY_NAME, displayName); + return this; + } + + public ChannelBuilder setInternalProviderId(String internalProviderId) { + mValues.put(TvContract.Channels.COLUMN_INTERNAL_PROVIDER_ID, internalProviderId); + return this; + } + + public ChannelBuilder setAppLinkIntent(Intent intent) { + mValues.put(TvContract.Channels.COLUMN_APP_LINK_INTENT_URI, toUriString(intent)); + return this; + } + + public ContentValues toContentValues() { + return new ContentValues(mValues); + } + + } +} diff --git a/app/src/main/java/com/limelight/utils/UiHelper.java b/app/src/main/java/com/limelight/utils/UiHelper.java old mode 100644 new mode 100755 index f6da70aa25..ec9b992887 --- a/app/src/main/java/com/limelight/utils/UiHelper.java +++ b/app/src/main/java/com/limelight/utils/UiHelper.java @@ -1,261 +1,266 @@ -package com.limelight.utils; - -import android.app.Activity; -import android.app.AlertDialog; -import android.app.GameManager; -import android.app.GameState; -import android.app.LocaleManager; -import android.app.UiModeManager; -import android.content.Context; -import android.content.DialogInterface; -import android.content.SharedPreferences; -import android.content.res.Configuration; -import android.graphics.Insets; -import android.os.Build; -import android.os.LocaleList; -import android.view.View; -import android.view.WindowInsets; -import android.view.WindowManager; - -import com.limelight.Game; -import com.limelight.R; -import com.limelight.nvstream.http.ComputerDetails; -import com.limelight.preferences.PreferenceConfiguration; - -import java.util.Locale; - -public class UiHelper { - - private static final int TV_VERTICAL_PADDING_DP = 15; - private static final int TV_HORIZONTAL_PADDING_DP = 15; - - private static void setGameModeStatus(Context context, boolean streaming, boolean interruptible) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - GameManager gameManager = context.getSystemService(GameManager.class); - - if (streaming) { - gameManager.setGameState(new GameState(false, interruptible ? GameState.MODE_GAMEPLAY_INTERRUPTIBLE : GameState.MODE_GAMEPLAY_UNINTERRUPTIBLE)); - } - else { - gameManager.setGameState(new GameState(false, GameState.MODE_NONE)); - } - } - } - - public static void notifyStreamConnecting(Context context) { - setGameModeStatus(context, true, true); - } - - public static void notifyStreamConnected(Context context) { - setGameModeStatus(context, true, false); - } - - public static void notifyStreamEnteringPiP(Context context) { - setGameModeStatus(context, true, true); - } - - public static void notifyStreamExitingPiP(Context context) { - setGameModeStatus(context, true, false); - } - - public static void notifyStreamEnded(Context context) { - setGameModeStatus(context, false, false); - } - - public static void setLocale(Activity activity) - { - String locale = PreferenceConfiguration.readPreferences(activity).language; - if (!locale.equals(PreferenceConfiguration.DEFAULT_LANGUAGE)) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - // On Android 13, migrate this non-default language setting into the OS native API - LocaleManager localeManager = activity.getSystemService(LocaleManager.class); - localeManager.setApplicationLocales(LocaleList.forLanguageTags(locale)); - PreferenceConfiguration.completeLanguagePreferenceMigration(activity); - } - else { - Configuration config = new Configuration(activity.getResources().getConfiguration()); - - // Some locales include both language and country which must be separated - // before calling the Locale constructor. - if (locale.contains("-")) - { - config.locale = new Locale(locale.substring(0, locale.indexOf('-')), - locale.substring(locale.indexOf('-') + 1)); - } - else - { - config.locale = new Locale(locale); - } - - activity.getResources().updateConfiguration(config, activity.getResources().getDisplayMetrics()); - } - } - } - - public static void applyStatusBarPadding(View view) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - // This applies the padding that we omitted in notifyNewRootView() on Q - view.setOnApplyWindowInsetsListener(new View.OnApplyWindowInsetsListener() { - @Override - public WindowInsets onApplyWindowInsets(View view, WindowInsets windowInsets) { - view.setPadding(view.getPaddingLeft(), - view.getPaddingTop(), - view.getPaddingRight(), - windowInsets.getTappableElementInsets().bottom); - return windowInsets; - } - }); - view.requestApplyInsets(); - } - } - - public static void notifyNewRootView(final Activity activity) - { - View rootView = activity.findViewById(android.R.id.content); - UiModeManager modeMgr = (UiModeManager) activity.getSystemService(Context.UI_MODE_SERVICE); - - // Set GameState.MODE_NONE initially for all activities - setGameModeStatus(activity, false, false); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - // Allow this non-streaming activity to layout under notches. - // - // We should NOT do this for the Game activity unless - // the user specifically opts in, because it can obscure - // parts of the streaming surface. - activity.getWindow().getAttributes().layoutInDisplayCutoutMode = - WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; - } - - if (modeMgr.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION) { - // Increase view padding on TVs - float scale = activity.getResources().getDisplayMetrics().density; - int verticalPaddingPixels = (int) (TV_VERTICAL_PADDING_DP*scale + 0.5f); - int horizontalPaddingPixels = (int) (TV_HORIZONTAL_PADDING_DP*scale + 0.5f); - - rootView.setPadding(horizontalPaddingPixels, verticalPaddingPixels, - horizontalPaddingPixels, verticalPaddingPixels); - } - else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - // Draw under the status bar on Android Q devices - - // Using getDecorView() here breaks the translucent status/navigation bar when gestures are disabled - activity.findViewById(android.R.id.content).setOnApplyWindowInsetsListener(new View.OnApplyWindowInsetsListener() { - @Override - public WindowInsets onApplyWindowInsets(View view, WindowInsets windowInsets) { - // Use the tappable insets so we can draw under the status bar in gesture mode - Insets tappableInsets = windowInsets.getTappableElementInsets(); - view.setPadding(tappableInsets.left, - tappableInsets.top, - tappableInsets.right, - 0); - - // Show a translucent navigation bar if we can't tap there - if (tappableInsets.bottom != 0) { - activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION); - } - else { - activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION); - } - - return windowInsets; - } - }); - - activity.getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION); - } - } - - public static void showDecoderCrashDialog(Activity activity) { - final SharedPreferences prefs = activity.getSharedPreferences("DecoderTombstone", 0); - final int crashCount = prefs.getInt("CrashCount", 0); - int lastNotifiedCrashCount = prefs.getInt("LastNotifiedCrashCount", 0); - - // Remember the last crash count we notified at, so we don't - // display the crash dialog every time the app is started until - // they stream again - if (crashCount != 0 && crashCount != lastNotifiedCrashCount) { - if (crashCount % 3 == 0) { - // At 3 consecutive crashes, we'll forcefully reset their settings - PreferenceConfiguration.resetStreamingSettings(activity); - Dialog.displayDialog(activity, - activity.getResources().getString(R.string.title_decoding_reset), - activity.getResources().getString(R.string.message_decoding_reset), - new Runnable() { - @Override - public void run() { - // Mark notification as acknowledged on dismissal - prefs.edit().putInt("LastNotifiedCrashCount", crashCount).apply(); - } - }); - } - else { - Dialog.displayDialog(activity, - activity.getResources().getString(R.string.title_decoding_error), - activity.getResources().getString(R.string.message_decoding_error), - new Runnable() { - @Override - public void run() { - // Mark notification as acknowledged on dismissal - prefs.edit().putInt("LastNotifiedCrashCount", crashCount).apply(); - } - }); - } - } - } - - public static void displayQuitConfirmationDialog(Activity parent, final Runnable onYes, final Runnable onNo) { - DialogInterface.OnClickListener dialogClickListener = new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - switch (which){ - case DialogInterface.BUTTON_POSITIVE: - if (onYes != null) { - onYes.run(); - } - break; - - case DialogInterface.BUTTON_NEGATIVE: - if (onNo != null) { - onNo.run(); - } - break; - } - } - }; - - AlertDialog.Builder builder = new AlertDialog.Builder(parent); - builder.setMessage(parent.getResources().getString(R.string.applist_quit_confirmation)) - .setPositiveButton(parent.getResources().getString(R.string.yes), dialogClickListener) - .setNegativeButton(parent.getResources().getString(R.string.no), dialogClickListener) - .show(); - } - - public static void displayDeletePcConfirmationDialog(Activity parent, ComputerDetails computer, final Runnable onYes, final Runnable onNo) { - DialogInterface.OnClickListener dialogClickListener = new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - switch (which){ - case DialogInterface.BUTTON_POSITIVE: - if (onYes != null) { - onYes.run(); - } - break; - - case DialogInterface.BUTTON_NEGATIVE: - if (onNo != null) { - onNo.run(); - } - break; - } - } - }; - - AlertDialog.Builder builder = new AlertDialog.Builder(parent); - builder.setMessage(parent.getResources().getString(R.string.delete_pc_msg)) - .setTitle(computer.name) - .setPositiveButton(parent.getResources().getString(R.string.yes), dialogClickListener) - .setNegativeButton(parent.getResources().getString(R.string.no), dialogClickListener) - .show(); - } -} +package com.limelight.utils; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.GameManager; +import android.app.GameState; +import android.app.LocaleManager; +import android.app.UiModeManager; +import android.content.Context; +import android.content.DialogInterface; +import android.content.SharedPreferences; +import android.content.res.Configuration; +import android.graphics.Insets; +import android.os.Build; +import android.os.LocaleList; +import android.util.TypedValue; +import android.view.View; +import android.view.WindowInsets; +import android.view.WindowManager; + +import com.limelight.Game; +import com.limelight.R; +import com.limelight.nvstream.http.ComputerDetails; +import com.limelight.preferences.PreferenceConfiguration; + +import java.util.Locale; + +public class UiHelper { + + private static final int TV_VERTICAL_PADDING_DP = 15; + private static final int TV_HORIZONTAL_PADDING_DP = 15; + + private static void setGameModeStatus(Context context, boolean streaming, boolean interruptible) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + GameManager gameManager = context.getSystemService(GameManager.class); + + if (streaming) { + gameManager.setGameState(new GameState(false, interruptible ? GameState.MODE_GAMEPLAY_INTERRUPTIBLE : GameState.MODE_GAMEPLAY_UNINTERRUPTIBLE)); + } + else { + gameManager.setGameState(new GameState(false, GameState.MODE_NONE)); + } + } + } + + public static void notifyStreamConnecting(Context context) { + setGameModeStatus(context, true, true); + } + + public static void notifyStreamConnected(Context context) { + setGameModeStatus(context, true, false); + } + + public static void notifyStreamEnteringPiP(Context context) { + setGameModeStatus(context, true, true); + } + + public static void notifyStreamExitingPiP(Context context) { + setGameModeStatus(context, true, false); + } + + public static void notifyStreamEnded(Context context) { + setGameModeStatus(context, false, false); + } + + public static void setLocale(Activity activity) + { + String locale = PreferenceConfiguration.readPreferences(activity).language; + if (!locale.equals(PreferenceConfiguration.DEFAULT_LANGUAGE)) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + // On Android 13, migrate this non-default language setting into the OS native API + LocaleManager localeManager = activity.getSystemService(LocaleManager.class); + localeManager.setApplicationLocales(LocaleList.forLanguageTags(locale)); + PreferenceConfiguration.completeLanguagePreferenceMigration(activity); + } + else { + Configuration config = new Configuration(activity.getResources().getConfiguration()); + + // Some locales include both language and country which must be separated + // before calling the Locale constructor. + if (locale.contains("-")) + { + config.locale = new Locale(locale.substring(0, locale.indexOf('-')), + locale.substring(locale.indexOf('-') + 1)); + } + else + { + config.locale = new Locale(locale); + } + + activity.getResources().updateConfiguration(config, activity.getResources().getDisplayMetrics()); + } + } + } + + public static void applyStatusBarPadding(View view) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // This applies the padding that we omitted in notifyNewRootView() on Q + view.setOnApplyWindowInsetsListener(new View.OnApplyWindowInsetsListener() { + @Override + public WindowInsets onApplyWindowInsets(View view, WindowInsets windowInsets) { + view.setPadding(view.getPaddingLeft(), + view.getPaddingTop(), + view.getPaddingRight(), + windowInsets.getTappableElementInsets().bottom); + return windowInsets; + } + }); + view.requestApplyInsets(); + } + } + + public static void notifyNewRootView(final Activity activity) + { + View rootView = activity.findViewById(android.R.id.content); + UiModeManager modeMgr = (UiModeManager) activity.getSystemService(Context.UI_MODE_SERVICE); + + // Set GameState.MODE_NONE initially for all activities + setGameModeStatus(activity, false, false); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + // Allow this non-streaming activity to layout under notches. + // + // We should NOT do this for the Game activity unless + // the user specifically opts in, because it can obscure + // parts of the streaming surface. + activity.getWindow().getAttributes().layoutInDisplayCutoutMode = + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; + } + + if (modeMgr.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION) { + // Increase view padding on TVs + float scale = activity.getResources().getDisplayMetrics().density; + int verticalPaddingPixels = (int) (TV_VERTICAL_PADDING_DP*scale + 0.5f); + int horizontalPaddingPixels = (int) (TV_HORIZONTAL_PADDING_DP*scale + 0.5f); + + rootView.setPadding(horizontalPaddingPixels, verticalPaddingPixels, + horizontalPaddingPixels, verticalPaddingPixels); + } + else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // Draw under the status bar on Android Q devices + + // Using getDecorView() here breaks the translucent status/navigation bar when gestures are disabled + activity.findViewById(android.R.id.content).setOnApplyWindowInsetsListener(new View.OnApplyWindowInsetsListener() { + @Override + public WindowInsets onApplyWindowInsets(View view, WindowInsets windowInsets) { + // Use the tappable insets so we can draw under the status bar in gesture mode + Insets tappableInsets = windowInsets.getTappableElementInsets(); + view.setPadding(tappableInsets.left, + tappableInsets.top, + tappableInsets.right, + 0); + + // Show a translucent navigation bar if we can't tap there + if (tappableInsets.bottom != 0) { + activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION); + } + else { + activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION); + } + + return windowInsets; + } + }); + + activity.getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION); + } + } + + public static void showDecoderCrashDialog(Activity activity) { + final SharedPreferences prefs = activity.getSharedPreferences("DecoderTombstone", 0); + final int crashCount = prefs.getInt("CrashCount", 0); + int lastNotifiedCrashCount = prefs.getInt("LastNotifiedCrashCount", 0); + + // Remember the last crash count we notified at, so we don't + // display the crash dialog every time the app is started until + // they stream again + if (crashCount != 0 && crashCount != lastNotifiedCrashCount) { + if (crashCount % 3 == 0) { + // At 3 consecutive crashes, we'll forcefully reset their settings + PreferenceConfiguration.resetStreamingSettings(activity); + Dialog.displayDialog(activity, + activity.getResources().getString(R.string.title_decoding_reset), + activity.getResources().getString(R.string.message_decoding_reset), + new Runnable() { + @Override + public void run() { + // Mark notification as acknowledged on dismissal + prefs.edit().putInt("LastNotifiedCrashCount", crashCount).apply(); + } + }); + } + else { + Dialog.displayDialog(activity, + activity.getResources().getString(R.string.title_decoding_error), + activity.getResources().getString(R.string.message_decoding_error), + new Runnable() { + @Override + public void run() { + // Mark notification as acknowledged on dismissal + prefs.edit().putInt("LastNotifiedCrashCount", crashCount).apply(); + } + }); + } + } + } + + public static void displayQuitConfirmationDialog(Activity parent, final Runnable onYes, final Runnable onNo) { + DialogInterface.OnClickListener dialogClickListener = new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + switch (which){ + case DialogInterface.BUTTON_POSITIVE: + if (onYes != null) { + onYes.run(); + } + break; + + case DialogInterface.BUTTON_NEGATIVE: + if (onNo != null) { + onNo.run(); + } + break; + } + } + }; + + AlertDialog.Builder builder = new AlertDialog.Builder(parent); + builder.setMessage(parent.getResources().getString(R.string.applist_quit_confirmation)) + .setPositiveButton(parent.getResources().getString(R.string.yes), dialogClickListener) + .setNegativeButton(parent.getResources().getString(R.string.no), dialogClickListener) + .show(); + } + + public static void displayDeletePcConfirmationDialog(Activity parent, ComputerDetails computer, final Runnable onYes, final Runnable onNo) { + DialogInterface.OnClickListener dialogClickListener = new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + switch (which){ + case DialogInterface.BUTTON_POSITIVE: + if (onYes != null) { + onYes.run(); + } + break; + + case DialogInterface.BUTTON_NEGATIVE: + if (onNo != null) { + onNo.run(); + } + break; + } + } + }; + + AlertDialog.Builder builder = new AlertDialog.Builder(parent); + builder.setMessage(parent.getResources().getString(R.string.delete_pc_msg)) + .setTitle(computer.name) + .setPositiveButton(parent.getResources().getString(R.string.yes), dialogClickListener) + .setNegativeButton(parent.getResources().getString(R.string.no), dialogClickListener) + .show(); + } + + public static float dpToPx(Context context, float dp) { + return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, context.getResources().getDisplayMetrics()); + } +} diff --git a/app/src/main/java/com/limelight/utils/Vector2d.java b/app/src/main/java/com/limelight/utils/Vector2d.java old mode 100644 new mode 100755 index a8178a2bf9..b2ea089311 --- a/app/src/main/java/com/limelight/utils/Vector2d.java +++ b/app/src/main/java/com/limelight/utils/Vector2d.java @@ -1,47 +1,47 @@ -package com.limelight.utils; - -public class Vector2d { - private float x; - private float y; - private double magnitude; - - public static final Vector2d ZERO = new Vector2d(); - - public Vector2d() { - initialize(0, 0); - } - - public void initialize(float x, float y) { - this.x = x; - this.y = y; - this.magnitude = Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2)); - } - - public double getMagnitude() { - return magnitude; - } - - public void getNormalized(Vector2d vector) { - vector.initialize((float)(x / magnitude), (float)(y / magnitude)); - } - - public void scalarMultiply(double factor) { - initialize((float)(x * factor), (float)(y * factor)); - } - - public void setX(float x) { - initialize(x, this.y); - } - - public void setY(float y) { - initialize(this.x, y); - } - - public float getX() { - return x; - } - - public float getY() { - return y; - } -} +package com.limelight.utils; + +public class Vector2d { + private float x; + private float y; + private double magnitude; + + public static final Vector2d ZERO = new Vector2d(); + + public Vector2d() { + initialize(0, 0); + } + + public void initialize(float x, float y) { + this.x = x; + this.y = y; + this.magnitude = Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2)); + } + + public double getMagnitude() { + return magnitude; + } + + public void getNormalized(Vector2d vector) { + vector.initialize((float)(x / magnitude), (float)(y / magnitude)); + } + + public void scalarMultiply(double factor) { + initialize((float)(x * factor), (float)(y * factor)); + } + + public void setX(float x) { + initialize(x, this.y); + } + + public void setY(float y) { + initialize(this.x, y); + } + + public float getX() { + return x; + } + + public float getY() { + return y; + } +} diff --git a/app/src/main/res/anim/boxart_fadein.xml b/app/src/main/res/anim/boxart_fadein.xml old mode 100644 new mode 100755 index 334481bba2..f7bd1c76c9 --- a/app/src/main/res/anim/boxart_fadein.xml +++ b/app/src/main/res/anim/boxart_fadein.xml @@ -1,8 +1,8 @@ - - - + + + \ No newline at end of file diff --git a/app/src/main/res/anim/boxart_fadeout.xml b/app/src/main/res/anim/boxart_fadeout.xml old mode 100644 new mode 100755 index 579c5a3f35..ebe11aaf7c --- a/app/src/main/res/anim/boxart_fadeout.xml +++ b/app/src/main/res/anim/boxart_fadeout.xml @@ -1,8 +1,8 @@ - - - + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-xhdpi/atv_banner.png b/app/src/main/res/drawable-xhdpi/atv_banner.png old mode 100644 new mode 100755 diff --git a/app/src/main/res/drawable-xhdpi/no_app_image.png b/app/src/main/res/drawable-xhdpi/no_app_image.png old mode 100644 new mode 100755 diff --git a/app/src/main/res/drawable-xhdpi/ouya_icon.png b/app/src/main/res/drawable-xhdpi/ouya_icon.png old mode 100644 new mode 100755 diff --git a/app/src/main/res/drawable/app_icon.png b/app/src/main/res/drawable/app_icon.png old mode 100644 new mode 100755 diff --git a/app/src/main/res/drawable/bg_ax_keyboard_button.xml b/app/src/main/res/drawable/bg_ax_keyboard_button.xml new file mode 100755 index 0000000000..27c93123ea --- /dev/null +++ b/app/src/main/res/drawable/bg_ax_keyboard_button.xml @@ -0,0 +1,13 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_ax_keyboard_button_confirm.xml b/app/src/main/res/drawable/bg_ax_keyboard_button_confirm.xml new file mode 100755 index 0000000000..7ed3876ec0 --- /dev/null +++ b/app/src/main/res/drawable/bg_ax_keyboard_button_confirm.xml @@ -0,0 +1,13 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_a.xml b/app/src/main/res/drawable/facebutton_a.xml new file mode 100755 index 0000000000..afb19e389d --- /dev/null +++ b/app/src/main/res/drawable/facebutton_a.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_a_press.xml b/app/src/main/res/drawable/facebutton_a_press.xml new file mode 100755 index 0000000000..60f66737bf --- /dev/null +++ b/app/src/main/res/drawable/facebutton_a_press.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_b.xml b/app/src/main/res/drawable/facebutton_b.xml new file mode 100755 index 0000000000..b2471c2c5e --- /dev/null +++ b/app/src/main/res/drawable/facebutton_b.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_b_press.xml b/app/src/main/res/drawable/facebutton_b_press.xml new file mode 100755 index 0000000000..fd2e478a58 --- /dev/null +++ b/app/src/main/res/drawable/facebutton_b_press.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_dpad.xml b/app/src/main/res/drawable/facebutton_dpad.xml new file mode 100755 index 0000000000..2ba8c05c4e --- /dev/null +++ b/app/src/main/res/drawable/facebutton_dpad.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_dpad_up.xml b/app/src/main/res/drawable/facebutton_dpad_up.xml new file mode 100755 index 0000000000..ed99e8cede --- /dev/null +++ b/app/src/main/res/drawable/facebutton_dpad_up.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_dpad_up_right.xml b/app/src/main/res/drawable/facebutton_dpad_up_right.xml new file mode 100755 index 0000000000..e6bb8dc30d --- /dev/null +++ b/app/src/main/res/drawable/facebutton_dpad_up_right.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_l.xml b/app/src/main/res/drawable/facebutton_l.xml new file mode 100755 index 0000000000..6da1a95abb --- /dev/null +++ b/app/src/main/res/drawable/facebutton_l.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_l3.xml b/app/src/main/res/drawable/facebutton_l3.xml new file mode 100755 index 0000000000..150d8e11c9 --- /dev/null +++ b/app/src/main/res/drawable/facebutton_l3.xml @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_l3_press.xml b/app/src/main/res/drawable/facebutton_l3_press.xml new file mode 100755 index 0000000000..566603b827 --- /dev/null +++ b/app/src/main/res/drawable/facebutton_l3_press.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_l_press.xml b/app/src/main/res/drawable/facebutton_l_press.xml new file mode 100755 index 0000000000..8002802fac --- /dev/null +++ b/app/src/main/res/drawable/facebutton_l_press.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_minus.xml b/app/src/main/res/drawable/facebutton_minus.xml new file mode 100755 index 0000000000..588e75cf3b --- /dev/null +++ b/app/src/main/res/drawable/facebutton_minus.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_minus_press.xml b/app/src/main/res/drawable/facebutton_minus_press.xml new file mode 100755 index 0000000000..fb33a6ff02 --- /dev/null +++ b/app/src/main/res/drawable/facebutton_minus_press.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_plus.xml b/app/src/main/res/drawable/facebutton_plus.xml new file mode 100755 index 0000000000..e51faea753 --- /dev/null +++ b/app/src/main/res/drawable/facebutton_plus.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_plus_press.xml b/app/src/main/res/drawable/facebutton_plus_press.xml new file mode 100755 index 0000000000..791e83489f --- /dev/null +++ b/app/src/main/res/drawable/facebutton_plus_press.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_r.xml b/app/src/main/res/drawable/facebutton_r.xml new file mode 100755 index 0000000000..c0db1c4976 --- /dev/null +++ b/app/src/main/res/drawable/facebutton_r.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_r3.xml b/app/src/main/res/drawable/facebutton_r3.xml new file mode 100755 index 0000000000..a84e08d038 --- /dev/null +++ b/app/src/main/res/drawable/facebutton_r3.xml @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_r3_press.xml b/app/src/main/res/drawable/facebutton_r3_press.xml new file mode 100755 index 0000000000..68ee63b2aa --- /dev/null +++ b/app/src/main/res/drawable/facebutton_r3_press.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_r_press.xml b/app/src/main/res/drawable/facebutton_r_press.xml new file mode 100755 index 0000000000..c93bc6d80e --- /dev/null +++ b/app/src/main/res/drawable/facebutton_r_press.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_touchpad.xml b/app/src/main/res/drawable/facebutton_touchpad.xml new file mode 100755 index 0000000000..da199d6089 --- /dev/null +++ b/app/src/main/res/drawable/facebutton_touchpad.xml @@ -0,0 +1,12 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_touchpad_press.xml b/app/src/main/res/drawable/facebutton_touchpad_press.xml new file mode 100755 index 0000000000..1f501b3f9d --- /dev/null +++ b/app/src/main/res/drawable/facebutton_touchpad_press.xml @@ -0,0 +1,12 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_x.xml b/app/src/main/res/drawable/facebutton_x.xml new file mode 100755 index 0000000000..67efd42934 --- /dev/null +++ b/app/src/main/res/drawable/facebutton_x.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_x_press.xml b/app/src/main/res/drawable/facebutton_x_press.xml new file mode 100755 index 0000000000..d4d7b2be7b --- /dev/null +++ b/app/src/main/res/drawable/facebutton_x_press.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_y.xml b/app/src/main/res/drawable/facebutton_y.xml new file mode 100755 index 0000000000..5c590274c8 --- /dev/null +++ b/app/src/main/res/drawable/facebutton_y.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_y_press.xml b/app/src/main/res/drawable/facebutton_y_press.xml new file mode 100755 index 0000000000..05b31ce7fa --- /dev/null +++ b/app/src/main/res/drawable/facebutton_y_press.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_zl.xml b/app/src/main/res/drawable/facebutton_zl.xml new file mode 100755 index 0000000000..836b748a72 --- /dev/null +++ b/app/src/main/res/drawable/facebutton_zl.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_zl_press.xml b/app/src/main/res/drawable/facebutton_zl_press.xml new file mode 100755 index 0000000000..9c2e9e24fa --- /dev/null +++ b/app/src/main/res/drawable/facebutton_zl_press.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_zr.xml b/app/src/main/res/drawable/facebutton_zr.xml new file mode 100755 index 0000000000..18a4b70aed --- /dev/null +++ b/app/src/main/res/drawable/facebutton_zr.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_zr_press.xml b/app/src/main/res/drawable/facebutton_zr_press.xml new file mode 100755 index 0000000000..e2e6effb03 --- /dev/null +++ b/app/src/main/res/drawable/facebutton_zr_press.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_add.xml b/app/src/main/res/drawable/ic_add.xml old mode 100644 new mode 100755 index 2f118b3fd1..5a6140aa58 --- a/app/src/main/res/drawable/ic_add.xml +++ b/app/src/main/res/drawable/ic_add.xml @@ -1,9 +1,9 @@ - - - + + + diff --git a/app/src/main/res/drawable/ic_channel.xml b/app/src/main/res/drawable/ic_channel.xml old mode 100644 new mode 100755 index 4dada9a7f4..65d33b933a --- a/app/src/main/res/drawable/ic_channel.xml +++ b/app/src/main/res/drawable/ic_channel.xml @@ -1,10 +1,10 @@ - - - - - + + + + + diff --git a/app/src/main/res/drawable/ic_computer.xml b/app/src/main/res/drawable/ic_computer.xml old mode 100644 new mode 100755 index 2617515737..bfa8a02686 --- a/app/src/main/res/drawable/ic_computer.xml +++ b/app/src/main/res/drawable/ic_computer.xml @@ -1,9 +1,9 @@ - - - + + + diff --git a/app/src/main/res/drawable/ic_help.xml b/app/src/main/res/drawable/ic_help.xml old mode 100644 new mode 100755 index d0b4755fc1..d17bdf218f --- a/app/src/main/res/drawable/ic_help.xml +++ b/app/src/main/res/drawable/ic_help.xml @@ -1,9 +1,9 @@ - - - + + + diff --git a/app/src/main/res/drawable/ic_hud_bg.xml b/app/src/main/res/drawable/ic_hud_bg.xml new file mode 100755 index 0000000000..d2cb12f187 --- /dev/null +++ b/app/src/main/res/drawable/ic_hud_bg.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_keyboard_setting.xml b/app/src/main/res/drawable/ic_keyboard_setting.xml new file mode 100755 index 0000000000..2ddf0f1f7a --- /dev/null +++ b/app/src/main/res/drawable/ic_keyboard_setting.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_lime_layer.xml b/app/src/main/res/drawable/ic_lime_layer.xml old mode 100644 new mode 100755 index 74ce061aba..42e6af44ec --- a/app/src/main/res/drawable/ic_lime_layer.xml +++ b/app/src/main/res/drawable/ic_lime_layer.xml @@ -1,19 +1,19 @@ - - - - - - - - - - + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_lock.xml b/app/src/main/res/drawable/ic_lock.xml old mode 100644 new mode 100755 index ad5e3a9860..6782f70e90 --- a/app/src/main/res/drawable/ic_lock.xml +++ b/app/src/main/res/drawable/ic_lock.xml @@ -1,9 +1,9 @@ - - - + + + diff --git a/app/src/main/res/drawable/ic_pc_offline.xml b/app/src/main/res/drawable/ic_pc_offline.xml old mode 100644 new mode 100755 index 983ddf4d7e..d15ee9c7a4 --- a/app/src/main/res/drawable/ic_pc_offline.xml +++ b/app/src/main/res/drawable/ic_pc_offline.xml @@ -1,9 +1,9 @@ - - - + + + diff --git a/app/src/main/res/drawable/ic_play.xml b/app/src/main/res/drawable/ic_play.xml old mode 100644 new mode 100755 index 83a91438e9..52db01aea3 --- a/app/src/main/res/drawable/ic_play.xml +++ b/app/src/main/res/drawable/ic_play.xml @@ -1,9 +1,9 @@ - - - + + + diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml old mode 100644 new mode 100755 index 0286f33133..f6a56f2b29 --- a/app/src/main/res/drawable/ic_settings.xml +++ b/app/src/main/res/drawable/ic_settings.xml @@ -1,9 +1,9 @@ - - - + + + diff --git a/app/src/main/res/drawable/list_view_unselected.xml b/app/src/main/res/drawable/list_view_unselected.xml old mode 100644 new mode 100755 index b691f3a27b..6decc513f0 --- a/app/src/main/res/drawable/list_view_unselected.xml +++ b/app/src/main/res/drawable/list_view_unselected.xml @@ -1,7 +1,7 @@ - - - - - + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-land/activity_pc_view.xml b/app/src/main/res/layout-land/activity_pc_view.xml old mode 100644 new mode 100755 index f745fe0a04..bc81ef441e --- a/app/src/main/res/layout-land/activity_pc_view.xml +++ b/app/src/main/res/layout-land/activity_pc_view.xml @@ -1,84 +1,84 @@ - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_add_computer_manually.xml b/app/src/main/res/layout/activity_add_computer_manually.xml old mode 100644 new mode 100755 index 80b921df86..ed50efcbb9 --- a/app/src/main/res/layout/activity_add_computer_manually.xml +++ b/app/src/main/res/layout/activity_add_computer_manually.xml @@ -1,51 +1,51 @@ - - - - - - - - - -