1 // OpenTween - Client of Twitter
2 // Copyright (c) 2007-2011 kiri_feather (@kiri_feather) <kiri.feather@gmail.com>
3 // (c) 2008-2011 Moz (@syo68k)
4 // (c) 2008-2011 takeshik (@takeshik) <http://www.takeshik.org/>
5 // (c) 2010-2011 anis774 (@anis774) <http://d.hatena.ne.jp/anis774/>
6 // (c) 2010-2011 fantasticswallow (@f_swallow) <http://twitter.com/f_swallow>
7 // (c) 2011 Egtra (@egtra) <http://dev.activebasic.com/egtra/>
8 // (c) 2011 kim_upsilon (@kim_upsilon) <https://upsilo.net/~upsilon/>
9 // All rights reserved.
11 // This file is part of OpenTween.
13 // This program is free software; you can redistribute it and/or modify it
14 // under the terms of the GNU General public License as published by the Free
15 // Software Foundation; either version 3 of the License, or (at your option)
18 // This program is distributed in the hope that it will be useful, but
19 // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
20 // or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General public License
23 // You should have received a copy of the GNU General public License along
24 // with this program. if not, see <http://www.gnu.org/licenses/>, or write to
25 // the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor,
26 // Boston, MA 02110-1301, USA.
31 using System.Collections.Generic;
35 using System.Windows.Forms;
37 using System.Globalization;
38 using System.Security.Cryptography;
40 using System.Drawing.Imaging;
41 using System.Collections;
42 using System.Security.Principal;
43 using System.Runtime.Serialization.Json;
44 using System.Reflection;
45 using System.Diagnostics;
46 using System.Text.RegularExpressions;
48 using System.Net.Http;
49 using System.Net.NetworkInformation;
50 using System.Runtime.InteropServices;
52 using OpenTween.Models;
53 using OpenTween.Setting;
54 using System.Diagnostics.CodeAnalysis;
58 public static class MyCommon
60 private static readonly object LockObj = new object();
61 public static bool _endingFlag; //終了フラグ
62 public static string settingPath = null!;
73 public enum NameBalloonEnum
80 public enum DispTitleEnum
92 public enum LogUnitEnum
99 public enum UploadFileType
106 public enum UrlConverter
120 public enum HITRESULT
129 public enum HttpTimeOut
136 //Backgroundworkerへ処理種別を通知するための引数用enum
137 public enum WORKERTYPE
141 DirectMessegeRcv, //受信DM取得
142 DirectMessegeSnt, //送信DM取得
143 PostMessage, //発言POST
146 Follower, //Followerリスト取得
152 UserTimeline, //UserTimeline
153 BlockIds, //Blocking/ids
154 Configuration, //Twitter Configuration読み込み
155 NoRetweetIds, //RT非表示ユーザー取得
157 ErrorState, //エラー表示のみで後処理終了(認証エラー時など)
160 public static class DEFAULTTAB
162 public const string RECENT = "Recent";
163 public const string REPLY = "Reply";
164 public const string DM = "Direct";
165 public const string FAV = "Favorites";
166 public static readonly string MUTE = Properties.Resources.MuteTabName;
169 public static readonly object? Block = null;
170 public static bool TraceFlag = false;
173 public static bool DebugBuild = true;
175 public static bool DebugBuild = false;
178 public enum ACCOUNT_STATE
184 public enum REPLY_ICONSTATE
191 public static _Assembly EntryAssembly { get; internal set; }
192 public static string FileVersion { get; internal set; }
196 var assembly = Assembly.GetExecutingAssembly();
197 MyCommon.EntryAssembly = assembly;
199 var assemblyVersion = assembly.GetName().Version;
200 MyCommon.FileVersion = assemblyVersion.ToString();
203 public static string GetErrorLogPath()
204 => Path.Combine(Path.GetDirectoryName(MyCommon.EntryAssembly.Location), "ErrorLogs");
206 public static void TraceOut(WebApiException ex)
208 var message = ExceptionOutMessage(ex);
210 if (ex.ResponseText != null)
211 message += Environment.NewLine + "------- Response Data -------" + Environment.NewLine + ex.ResponseText;
213 TraceOut(TraceFlag, message);
216 public static void TraceOut(Exception ex, string Message)
218 var buf = ExceptionOutMessage(ex);
219 TraceOut(TraceFlag, Message + Environment.NewLine + buf);
222 public static void TraceOut(string Message)
223 => TraceOut(TraceFlag, Message);
225 public static void TraceOut(bool OutputFlag, string Message)
229 if (!OutputFlag) return;
231 var logPath = MyCommon.GetErrorLogPath();
232 if (!Directory.Exists(logPath))
233 Directory.CreateDirectory(logPath);
235 var now = DateTimeUtc.Now;
236 var fileName = $"{ApplicationSettings.AssemblyName}Trace-{now.ToLocalTime():yyyyMMdd-HHmmss}.log";
237 fileName = Path.Combine(logPath, fileName);
239 using var writer = new StreamWriter(fileName);
241 writer.WriteLine("**** TraceOut: {0} ****", now.ToLocalTimeString());
242 writer.WriteLine(Properties.Resources.TraceOutText1, ApplicationSettings.FeedbackEmailAddress);
243 writer.WriteLine(Properties.Resources.TraceOutText2, ApplicationSettings.FeedbackTwitterName);
245 writer.WriteLine(Properties.Resources.TraceOutText3);
246 writer.WriteLine(Properties.Resources.TraceOutText4, Environment.OSVersion.VersionString);
247 writer.WriteLine(Properties.Resources.TraceOutText5, Environment.Version);
248 writer.WriteLine(Properties.Resources.TraceOutText6, ApplicationSettings.AssemblyName, FileVersion);
249 writer.WriteLine(Message);
255 // 注意:最終的にファイル出力されるエラーログに記録されるため次の情報は書き出さない
257 // Dataプロパティにある終了許可フラグのパースもここで行う
259 public static string ExceptionOutMessage(Exception ex)
261 var IsTerminatePermission = true;
262 return ExceptionOutMessage(ex, ref IsTerminatePermission);
265 public static string ExceptionOutMessage(Exception ex, ref bool IsTerminatePermission)
267 if (ex == null) return "";
269 var buf = new StringBuilder();
271 buf.AppendFormat(Properties.Resources.UnhandledExceptionText8, ex.GetType().FullName, ex.Message);
275 var needHeader = true;
276 foreach (DictionaryEntry dt in ex.Data)
281 buf.AppendLine("-------Extra Information-------");
284 buf.AppendFormat("{0} : {1}", dt.Key, dt.Value);
286 if (dt.Key.Equals("IsTerminatePermission"))
288 IsTerminatePermission = (bool)dt.Value;
293 buf.AppendLine("-----End Extra Information-----");
296 buf.AppendLine(ex.StackTrace);
299 //InnerExceptionが存在する場合書き出す
300 var _ex = ex.InnerException;
304 buf.AppendFormat("-----InnerException[{0}]-----\r\n", nesting);
306 buf.AppendFormat(Properties.Resources.UnhandledExceptionText8, _ex.GetType().FullName, _ex.Message);
308 if (_ex.Data != null)
310 var needHeader = true;
312 foreach (DictionaryEntry dt in _ex.Data)
317 buf.AppendLine("-------Extra Information-------");
320 buf.AppendFormat("{0} : {1}", dt.Key, dt.Value);
321 if (dt.Key.Equals("IsTerminatePermission"))
323 IsTerminatePermission = (bool)dt.Value;
328 buf.AppendLine("-----End Extra Information-----");
331 buf.AppendLine(_ex.StackTrace);
334 _ex = _ex.InnerException;
336 return buf.ToString();
339 public static bool ExceptionOut(Exception ex)
343 var IsTerminatePermission = true;
345 var ident = WindowsIdentity.GetCurrent();
346 var princ = new WindowsPrincipal(ident);
347 var now = DateTimeUtc.Now;
349 var errorReport = string.Join(Environment.NewLine,
350 string.Format(Properties.Resources.UnhandledExceptionText1, now.ToLocalTimeString()),
353 string.Format(Properties.Resources.UnhandledExceptionText11 + princ.IsInRole(WindowsBuiltInRole.Administrator)),
354 string.Format(Properties.Resources.UnhandledExceptionText12 + princ.IsInRole(WindowsBuiltInRole.User)),
357 // OSVersion,AppVersion書き出し
358 string.Format(Properties.Resources.UnhandledExceptionText4),
359 string.Format(Properties.Resources.UnhandledExceptionText5, Environment.OSVersion.VersionString),
360 string.Format(Properties.Resources.UnhandledExceptionText6, Environment.Version),
361 string.Format(Properties.Resources.UnhandledExceptionText7, ApplicationSettings.AssemblyName, FileVersion),
363 ExceptionOutMessage(ex, ref IsTerminatePermission));
365 var logPath = MyCommon.GetErrorLogPath();
366 if (!Directory.Exists(logPath))
367 Directory.CreateDirectory(logPath);
369 var fileName = $"{ApplicationSettings.AssemblyName}-{now.ToLocalTime():yyyyMMdd-HHmmss}.log";
370 using (var writer = new StreamWriter(Path.Combine(logPath, fileName)))
372 writer.Write(errorReport);
375 var settings = SettingManager.Common;
376 var mainForm = Application.OpenForms.OfType<TweenMain>().FirstOrDefault();
379 if (mainForm != null && !mainForm.IsDisposed)
380 report = new ErrorReport(mainForm.TwitterInstance, errorReport);
382 report = new ErrorReport(errorReport);
384 report.AnonymousReport = settings.ErrorReportAnonymous;
386 OpenErrorReportDialog(mainForm, report);
388 // ダイアログ内で設定が変更されていれば保存する
389 if (settings.ErrorReportAnonymous != report.AnonymousReport)
391 settings.ErrorReportAnonymous = report.AnonymousReport;
399 private static void OpenErrorReportDialog(Form? owner, ErrorReport report)
401 if (owner != null && owner.InvokeRequired)
403 owner.Invoke((Action)(() => OpenErrorReportDialog(owner, report)));
407 using var dialog = new SendErrorReportForm();
408 dialog.ErrorReport = report;
409 dialog.ShowDialog(owner);
413 /// URLのドメイン名をPunycode展開します。
415 /// ドメイン名がIDNでない場合はそのまま返します。
416 /// ドメインラベルの区切り文字はFULLSTOP(.、U002E)に置き換えられます。
419 /// <param name="inputUrl">展開対象のURL</param>
420 /// <returns>IDNが含まれていた場合はPunycodeに展開したURLをを返します。Punycode展開時にエラーが発生した場合はnullを返します。</returns>
421 public static string? IDNEncode(string inputUrl)
425 var uriBuilder = new UriBuilder(inputUrl);
427 var idnConverter = new IdnMapping();
428 uriBuilder.Host = idnConverter.GetAscii(uriBuilder.Host);
430 return uriBuilder.Uri.AbsoluteUri;
438 public static string IDNDecode(string inputUrl)
442 var uriBuilder = new UriBuilder(inputUrl);
444 if (uriBuilder.Host != null)
446 var idnConverter = new IdnMapping();
447 uriBuilder.Host = idnConverter.GetUnicode(uriBuilder.Host);
450 return uriBuilder.Uri.AbsoluteUri;
459 /// URL を画面上で人間に読みやすい文字列に変換する(エスケープ解除など)
461 public static string ConvertToReadableUrl(string inputUrl)
465 var outputUrl = inputUrl;
468 outputUrl = MyCommon.IDNDecode(outputUrl);
470 // URL内で特殊な意味を持つ記号は元の文字に変換されることを避けるために二重エスケープする
471 // 参考: Firefoxの losslessDecodeURI() 関数
472 // http://hg.mozilla.org/mozilla-central/annotate/FIREFOX_AURORA_27_BASE/browser/base/content/browser.js#l2128
473 outputUrl = Regex.Replace(outputUrl, @"%(2[3456BCF]|3[ABDF]|40)", @"%25$1", RegexOptions.IgnoreCase);
476 outputUrl = Uri.UnescapeDataString(outputUrl);
480 catch (UriFormatException)
486 public static void MoveArrayItem(int[] values, int idx_fr, int idx_to)
488 var moved_value = values[idx_fr];
489 var num_moved = Math.Abs(idx_fr - idx_to);
493 Array.Copy(values, idx_to, values,
494 idx_to + 1, num_moved);
498 Array.Copy(values, idx_fr + 1, values,
502 values[idx_to] = moved_value;
505 public static string EncryptString(string str)
507 if (MyCommon.IsNullOrEmpty(str)) return "";
510 var bytesIn = Encoding.UTF8.GetBytes(str);
512 //DESCryptoServiceProviderオブジェクトの作成
513 using var des = new DESCryptoServiceProvider();
517 var bytesKey = Encoding.UTF8.GetBytes("_tween_encrypt_key_");
519 des.Key = ResizeBytesArray(bytesKey, des.Key.Length);
520 des.IV = ResizeBytesArray(bytesKey, des.IV.Length);
522 MemoryStream? msOut = null;
523 ICryptoTransform? desdecrypt = null;
527 //暗号化されたデータを書き出すためのMemoryStream
528 msOut = new MemoryStream();
531 desdecrypt = des.CreateEncryptor();
533 //書き込むためのCryptoStreamの作成
534 using var cryptStream = new CryptoStream(msOut, desdecrypt, CryptoStreamMode.Write);
536 //Disposeが重複して呼ばれないようにする
542 cryptStream.Write(bytesIn, 0, bytesIn.Length);
543 cryptStream.FlushFinalBlock();
545 var bytesOut = msTmp.ToArray();
547 //Base64で文字列に変更して結果を返す
548 return Convert.ToBase64String(bytesOut);
553 desdecrypt?.Dispose();
557 public static string DecryptString(string str)
559 if (MyCommon.IsNullOrEmpty(str)) return "";
561 //DESCryptoServiceProviderオブジェクトの作成
562 using var des = new DESCryptoServiceProvider();
566 var bytesKey = Encoding.UTF8.GetBytes("_tween_encrypt_key_");
568 des.Key = ResizeBytesArray(bytesKey, des.Key.Length);
569 des.IV = ResizeBytesArray(bytesKey, des.IV.Length);
571 //Base64で文字列をバイト配列に戻す
572 var bytesIn = Convert.FromBase64String(str);
574 MemoryStream? msIn = null;
575 ICryptoTransform? desdecrypt = null;
576 CryptoStream? cryptStreem = null;
580 //暗号化されたデータを読み込むためのMemoryStream
581 msIn = new MemoryStream(bytesIn);
583 desdecrypt = des.CreateDecryptor();
584 //読み込むためのCryptoStreamの作成
585 cryptStreem = new CryptoStream(msIn, desdecrypt, CryptoStreamMode.Read);
587 //Disposeが重複して呼ばれないようにする
591 //復号化されたデータを取得するためのStreamReader
592 using var srOut = new StreamReader(cryptStreem, Encoding.UTF8);
594 //Disposeが重複して呼ばれないようにする
598 var result = srOut.ReadToEnd();
605 desdecrypt?.Dispose();
606 cryptStreem?.Dispose();
610 public static byte[] ResizeBytesArray(byte[] bytes,
613 var newBytes = new byte[newSize];
614 if (bytes.Length <= newSize)
616 foreach (var i in Enumerable.Range(0, bytes.Length))
618 newBytes[i] = bytes[i];
624 foreach (var i in Enumerable.Range(0, bytes.Length))
626 newBytes[pos] = unchecked((byte)(newBytes[pos] ^ bytes[i]));
628 if (pos >= newBytes.Length)
638 public enum TabUsageType
642 Mentions = 2, //Unique
643 DirectMessage = 4, //Unique
644 Favorites = 8, //Unique
646 LocalQuery = 32, //Pin(no save/no save query/distribute/no update(normal update))
647 Profile = 64, //Pin(save/no distribute/manual update)
648 PublicSearch = 128, //Pin(save/no distribute/auto update)
653 SearchResults = 4096,
656 public static TwitterApiStatus TwitterApiInfo = new TwitterApiStatus();
658 public static bool IsAnimatedGif(string filename)
663 img = Image.FromFile(filename);
664 if (img == null) return false;
665 if (img.RawFormat.Guid == ImageFormat.Gif.Guid)
667 var fd = new FrameDimension(img.FrameDimensionsList[0]);
668 var fd_count = img.GetFrameCount(fd);
690 public static DateTimeUtc DateTimeParse(string input)
692 var formats = new[] {
693 "ddd MMM dd HH:mm:ss zzzz yyyy",
694 "ddd, d MMM yyyy HH:mm:ss zzzz",
697 if (DateTimeUtc.TryParseExact(input, formats, DateTimeFormatInfo.InvariantInfo, out var result))
700 TraceOut("Parse Error(DateTimeFormat) : " + input);
702 return DateTimeUtc.Now;
705 public static T CreateDataFromJson<T>(string content)
707 var buf = Encoding.Unicode.GetBytes(content);
708 using var stream = new MemoryStream(buf);
709 var settings = new DataContractJsonSerializerSettings
711 UseSimpleDictionaryFormat = true,
713 return (T)((new DataContractJsonSerializer(typeof(T), settings)).ReadObject(stream));
716 public static bool IsNetworkAvailable()
720 return NetworkInterface.GetIsNetworkAvailable();
728 public static bool IsValidEmail(string strIn)
730 // Return true if strIn is in valid e-mail format.
731 return Regex.IsMatch(strIn,
732 @"^(?("")("".+?""@)|(([0-9a-zA-Z]((\.(?!\.))|[-!#\$%&'\*\+/=\?\^`\{\}\|~\w])*)(?<=[0-9a-zA-Z])@))" +
733 @"(?(\[)(\[(\d{1,3}\.){3}\d{1,3}\])|(([0-9a-zA-Z][-\w]*[0-9a-zA-Z]\.)+[a-zA-Z]{2,6}))$");
737 /// 指定された修飾キーが押されている状態かを取得します。
739 /// <param name="keys">状態を調べるキー</param>
740 /// <returns><paramref name="keys"/> で指定された修飾キーがすべて押されている状態であれば true。それ以外であれば false。</returns>
741 public static bool IsKeyDown(params Keys[] keys)
742 => MyCommon._IsKeyDown(Control.ModifierKeys, keys);
744 internal static bool _IsKeyDown(Keys modifierKeys, Keys[] targetKeys)
746 foreach (var key in targetKeys)
748 if ((modifierKeys & key) != key)
757 /// アプリケーションのアセンブリ名を取得します。
760 /// VB.NETの<code>My.Application.Info.AssemblyName</code>と(ほぼ)同じ動作をします。
762 /// <returns>アプリケーションのアセンブリ名</returns>
763 public static string GetAssemblyName()
764 => MyCommon.EntryAssembly.GetName().Name;
767 /// 文字列中に含まれる %AppName% をアプリケーション名に置換する
769 /// <param name="orig">対象となる文字列</param>
770 /// <returns>置換後の文字列</returns>
771 public static string ReplaceAppName(string orig)
772 => MyCommon.ReplaceAppName(orig, ApplicationSettings.ApplicationName);
775 /// 文字列中に含まれる %AppName% をアプリケーション名に置換する
777 /// <param name="orig">対象となる文字列</param>
778 /// <param name="appname">アプリケーション名</param>
779 /// <returns>置換後の文字列</returns>
780 public static string ReplaceAppName(string orig, string appname)
781 => orig.Replace("%AppName%", appname);
784 /// 表示用のバージョン番号の文字列を生成する
787 /// バージョン1.0.0.1のように末尾が0でない(=開発版)の場合は「1.0.1-beta1」が出力される
792 public static string GetReadableVersion(string? versionStr = null)
794 var version = Version.Parse(versionStr ?? MyCommon.FileVersion);
796 return GetReadableVersion(version);
800 /// 表示用のバージョン番号の文字列を生成する
803 /// バージョン1.0.0.1のように末尾が0でない(=開発版)の場合は「1.0.1-dev」のように出力される
808 public static string GetReadableVersion(Version version)
810 var versionNum = new[] { version.Major, version.Minor, version.Build, version.Revision };
812 if (versionNum[3] == 0)
814 return string.Format("{0}.{1}.{2}", versionNum[0], versionNum[1], versionNum[2]);
818 versionNum[2] = versionNum[2] + 1;
820 if (versionNum[3] == 1)
821 return string.Format("{0}.{1}.{2}-dev", versionNum[0], versionNum[1], versionNum[2]);
823 return string.Format("{0}.{1}.{2}-dev+build.{3}", versionNum[0], versionNum[1], versionNum[2], versionNum[3]);
827 public const string TwitterUrl = "https://twitter.com/";
829 public static string GetStatusUrl(PostClass post)
831 if (post.RetweetedId == null)
832 return GetStatusUrl(post.ScreenName, post.StatusId);
834 return GetStatusUrl(post.ScreenName, post.RetweetedId.Value);
837 public static string GetStatusUrl(string screenName, long statusId)
838 => TwitterUrl + screenName + "/status/" + statusId;
841 /// 指定された IDictionary を元にクエリ文字列を生成します
843 /// <param name="param">生成するクエリの key-value コレクション</param>
844 public static string BuildQueryString(IEnumerable<KeyValuePair<string, string>> param)
850 .Where(x => x.Value != null)
851 .Select(x => EscapeQueryString(x.Key) + '=' + EscapeQueryString(x.Value));
853 return string.Join("&", query);
856 // .NET 4.5+: Reserved characters のうち、Uriクラスによってエスケープ強制解除されてしまうものも最初から Unreserved として扱う
857 private static readonly HashSet<char> UnreservedChars =
858 new HashSet<char>("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!'()*:");
861 /// 2バイト文字も考慮したクエリ用エンコード
863 /// <param name="stringToEncode">エンコードする文字列</param>
864 /// <returns>エンコード結果文字列</returns>
865 public static string EscapeQueryString(string stringToEncode)
867 var sb = new StringBuilder(stringToEncode.Length * 2);
869 foreach (var b in Encoding.UTF8.GetBytes(stringToEncode))
871 if (UnreservedChars.Contains((char)b))
874 sb.AppendFormat("%{0:X2}", b);
877 return sb.ToString();
881 /// 指定された範囲の整数を昇順に列挙します
884 /// start, start + 1, start + 2, ..., end の範囲の数列を生成します
886 /// <param name="from">数列の先頭の値 (最小値)</param>
887 /// <param name="to">数列の末尾の値 (最大値)</param>
888 /// <returns>整数を列挙する IEnumerable インスタンス</returns>
889 public static IEnumerable<int> CountUp(int from, int to)
892 return Enumerable.Empty<int>();
894 return Enumerable.Range(from, to - from + 1);
898 /// 指定された範囲の整数を降順に列挙します
901 /// start, start - 1, start - 2, ..., end の範囲の数列を生成します
903 /// <param name="from">数列の先頭の値 (最大値)</param>
904 /// <param name="to">数列の末尾の値 (最小値)</param>
905 /// <returns>整数を列挙する IEnumerable インスタンス</returns>
906 public static IEnumerable<int> CountDown(int from, int to)
908 for (var i = from; i >= to; i--)
912 public static IEnumerable<int> CircularCountUp(int length, int startIndex)
915 throw new ArgumentOutOfRangeException(nameof(length));
916 if (startIndex < 0 || startIndex >= length)
917 throw new ArgumentOutOfRangeException(nameof(startIndex));
920 var indices = MyCommon.CountUp(startIndex, length - 1);
922 // 先頭 ... (startIndex - 1)
924 indices = indices.Concat(MyCommon.CountUp(0, startIndex - 1));
929 public static IEnumerable<int> CircularCountDown(int length, int startIndex)
932 throw new ArgumentOutOfRangeException(nameof(length));
933 if (startIndex < 0 || startIndex >= length)
934 throw new ArgumentOutOfRangeException(nameof(startIndex));
937 var indices = MyCommon.CountDown(startIndex, 0);
939 // 末尾 ... (startIndex + 1)
940 if (startIndex != length - 1)
941 indices = indices.Concat(MyCommon.CountDown(length - 1, startIndex + 1));
947 /// 2バイト文字も考慮したUrlエンコード
949 /// <param name="stringToEncode">エンコードする文字列</param>
950 /// <returns>エンコード結果文字列</returns>
951 public static string UrlEncode(string stringToEncode)
953 const string UnreservedChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~";
954 var sb = new StringBuilder();
955 var bytes = Encoding.UTF8.GetBytes(stringToEncode);
957 foreach (var b in bytes)
959 if (UnreservedChars.IndexOf((char)b) != -1)
962 sb.AppendFormat("%{0:X2}", b);
964 return sb.ToString();
967 public static bool IsNullOrEmpty([NotNullWhen(false)] string? value)
968 => string.IsNullOrEmpty(value);